From bfa61f33e1ef04b19cd43fb5c168ff5f882e7ca4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 9 Sep 2026 15:26:46 -0700 Subject: [PATCH 01/30] fix(slack): use runtime origin for onboarding links (#7690) --- apps/sim/lib/slack-search/onboarding.test.ts | 37 ++++++++++++++++++++ apps/sim/lib/slack-search/onboarding.ts | 4 +-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/slack-search/onboarding.test.ts diff --git a/apps/sim/lib/slack-search/onboarding.test.ts b/apps/sim/lib/slack-search/onboarding.test.ts new file mode 100644 index 00000000000..6e2d30f1a0c --- /dev/null +++ b/apps/sim/lib/slack-search/onboarding.test.ts @@ -0,0 +1,37 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getEnv } = vi.hoisted(() => ({ getEnv: vi.fn() })) + +vi.unmock('@/lib/core/utils/urls') +vi.mock('@/lib/core/config/env', () => ({ + env: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }, + getEnv, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isProd: true })) + +import { slackSearchOnboardingUrl } from '@/lib/slack-search/onboarding' + +describe('Slack onboarding URL', () => { + beforeEach(() => { + getEnv.mockReset() + }) + + it.each(['https://staging.example.com', 'https://app.example.com', 'https://search.example.org'])( + 'uses the runtime origin %s instead of the build-time localhost value', + (origin) => { + getEnv.mockImplementation((key: string) => + key === 'NEXT_PUBLIC_APP_URL' ? origin : undefined + ) + expect(slackSearchOnboardingUrl('opaque-token')).toBe( + `${origin}/slack-search/connect/opaque-token` + ) + } + ) + + it('fails when the runtime public URL is missing instead of using the build-time value', () => { + expect(() => slackSearchOnboardingUrl('opaque-token')).toThrow( + 'NEXT_PUBLIC_APP_URL must be configured' + ) + }) +}) diff --git a/apps/sim/lib/slack-search/onboarding.ts b/apps/sim/lib/slack-search/onboarding.ts index d5efdd9130a..3c80755cd70 100644 --- a/apps/sim/lib/slack-search/onboarding.ts +++ b/apps/sim/lib/slack-search/onboarding.ts @@ -1,4 +1,4 @@ -import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' import { organizationRoutes } from '@/lib/navigation/paths' export function slackSearchOnboardingPath(token: string) { @@ -12,7 +12,7 @@ export function slackSearchIntegrationsPath(organizationId: string, token: strin /** The destination is application-authored; model-generated links never enter onboarding. */ export function slackSearchOnboardingUrl(token: string) { - return new URL(slackSearchOnboardingPath(token), env.NEXT_PUBLIC_APP_URL).href + return new URL(slackSearchOnboardingPath(token), getBaseUrl()).href } export const SLACK_SEARCH_CONNECT_ACCOUNT = From a2f1ac7edc8493415cb656ef4f1c99088ca8d2e0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 9 Sep 2026 15:37:19 -0700 Subject: [PATCH 02/30] fix(navigation): land on workspaces from the app entry, not settings (#7692) The signed-in front door sent organization members whose organization has not been rolled out to workspace settings, so opening the app dropped them on the General settings form instead of their workspace. Send them to the workspace picker, which is where the entry pointed before the organization surface existed. The default landing never opens settings; the /o guards still fall back to settings for viewers who explicitly asked for the organization surface. Also stop the entry from bouncing a stale session cookie to /login. The proxy treats /home as an app surface and redirects cookie-less requests to /login before the route renders, and auth-disabled deployments always resolve an anonymous session, so a null session here always means a present-but-invalid cookie. Redirecting that to /login was bounced straight back by the proxy's presence-only cookie check, looping until the browser gave up and leaving the viewer unable to reach the login page at all. Hand off to the workspace loader instead, the one identity-recovery surface, which clears the stale cookies before navigating. Claude-Session: https://claude.ai/code/session_01CGkraEeFNiPCU4jb784FE4 Co-authored-by: Claude Opus 5 (1M context) --- apps/sim/app/home/page.test.tsx | 10 ++++++++-- apps/sim/app/home/page.tsx | 15 ++++++++++++++- .../navigation/organization-rollout.test.ts | 2 +- .../lib/navigation/resolve-app-entry.test.ts | 6 ++---- apps/sim/lib/navigation/resolve-app-entry.ts | 19 +++++++++---------- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx index 90760f97c44..329be1004ea 100644 --- a/apps/sim/app/home/page.test.tsx +++ b/apps/sim/app/home/page.test.tsx @@ -28,10 +28,16 @@ describe('AppEntryPage', () => { vi.clearAllMocks() }) - it('sends a signed-out visitor to login without resolving an entry', async () => { + /** + * The proxy sends cookie-less requests to /login before this route renders, so a + * null session here is always a stale cookie. Redirecting to /login would be + * bounced back by the proxy's presence-only cookie check, looping forever. + */ + it('sends a stale-cookie viewer to the recovery surface, never back to login', async () => { mockGetSession.mockResolvedValue(null) - await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login') + await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/workspace') + expect(mockRedirect).not.toHaveBeenCalledWith('/login') expect(mockResolveAppEntryPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx index 1965f6894c2..420b2b99877 100644 --- a/apps/sim/app/home/page.tsx +++ b/apps/sim/app/home/page.tsx @@ -1,5 +1,6 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' /** @@ -10,8 +11,20 @@ import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' */ export default async function AppEntryPage() { const session = await getSession() + + /** + * A missing session here is never a signed-out visitor: the proxy treats `/home` + * as an app surface and sends cookie-less requests to `/login` before this + * renders, and auth-disabled deployments always resolve an anonymous session. So + * this branch means the cookie is present but its session is gone — and + * redirecting to `/login` would be bounced straight back by the proxy, which + * reads cookie presence rather than validity, looping until the browser gives up. + * Hand off to the workspace loader instead: it is the app's one identity-recovery + * surface, and it clears the stale cookies through `recoverFromStaleSession` + * before navigating to `/login`. + */ if (!session?.user) { - redirect('/login') + redirect(WORKSPACES_PATH) } redirect(await resolveAppEntryPath(session)) diff --git a/apps/sim/lib/navigation/organization-rollout.test.ts b/apps/sim/lib/navigation/organization-rollout.test.ts index 961c9033e01..dee375993b4 100644 --- a/apps/sim/lib/navigation/organization-rollout.test.ts +++ b/apps/sim/lib/navigation/organization-rollout.test.ts @@ -66,7 +66,7 @@ describe('organization rollout during impersonation', () => { session: { impersonatedBy: 'platform-admin', activeOrganizationId: 'customer-org' }, } await expect(resolveAppEntryPath(impersonatedSession)).resolves.toBe( - knowledge && groups ? '/o/customer-org/home' : '/workspace?redirect=settings' + knowledge && groups ? '/o/customer-org/home' : '/workspace' ) expect(mocks.landing).toHaveBeenLastCalledWith('customer-member', 'customer-org') expect(mocks.platformAdmin).not.toHaveBeenCalled() diff --git a/apps/sim/lib/navigation/resolve-app-entry.test.ts b/apps/sim/lib/navigation/resolve-app-entry.test.ts index c34fd1f27f6..2985484df5a 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.test.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.test.ts @@ -37,12 +37,10 @@ describe('resolveAppEntryPath', () => { expect(mockSearchAvailable).toHaveBeenCalledWith({ organizationId: 'org-2' }) }) - it('opens full workspace settings when Search is disabled', async () => { + it('lands an organization member on the workspace picker when Search is disabled', async () => { mockResolveOrganizationLanding.mockResolvedValue('org-2') mockSearchAvailable.mockResolvedValue(false) - await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe( - '/workspace?redirect=settings' - ) + await expect(resolveAppEntryPath({ user: { id: 'viewer' } })).resolves.toBe('/workspace') }) it('lands a viewer with no organization on the workspace picker', async () => { diff --git a/apps/sim/lib/navigation/resolve-app-entry.ts b/apps/sim/lib/navigation/resolve-app-entry.ts index afa62a109ca..06c9ddd0dea 100644 --- a/apps/sim/lib/navigation/resolve-app-entry.ts +++ b/apps/sim/lib/navigation/resolve-app-entry.ts @@ -1,10 +1,6 @@ import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' -import { - organizationRoutes, - WORKSPACE_SETTINGS_PATH, - WORKSPACES_PATH, -} from '@/lib/navigation/paths' +import { organizationRoutes, WORKSPACES_PATH } from '@/lib/navigation/paths' import { resolveOrganizationLanding } from '@/lib/organizations/surface' interface EntrySession { @@ -12,8 +8,12 @@ interface EntrySession { } /** - * Routes organization members to Home when Search is enabled and workspace settings otherwise. - * Viewers without an organization land on the workspace picker. + * Routes organization members to Home when the organization surface is enabled for + * them. Everyone else — viewers without an organization, and members whose + * organization has not been rolled out — lands on the workspace picker, which is + * where the signed-in app's front door pointed before the organization surface + * existed. The default landing never opens settings: a viewer who did not ask for + * settings must not be dropped into them. */ export async function resolveAppEntryPath(session: EntrySession): Promise { const organizationId = await resolveOrganizationLanding( @@ -21,8 +21,7 @@ export async function resolveAppEntryPath(session: EntrySession): Promise Date: Wed, 9 Sep 2026 15:49:43 -0700 Subject: [PATCH 03/30] fix(organizations): align sidebar loading and chat actions (#7691) * fix(organizations): align sidebar loading and chat actions * fix(organizations): preserve chat context during pagination --- .../chats-section/chats-section.test.tsx | 163 ++++++++++-- .../chats-section/chats-section.tsx | 249 +++++++++++------- .../hooks/use-organization-chat-actions.ts | 128 +++++++++ .../organization-sidebar.tsx | 31 +-- .../app/o/[organizationId]/layout.test.tsx | 54 +++- apps/sim/app/o/[organizationId]/layout.tsx | 14 +- .../app/o/[organizationId]/prefetch.test.ts | 180 +++++++++++++ apps/sim/app/o/[organizationId]/prefetch.ts | 38 +++ .../app/workspace/[workspaceId]/prefetch.ts | 52 +--- .../context-menu/context-menu.test.tsx | 60 +++++ .../components/context-menu/context-menu.tsx | 21 +- apps/sim/hooks/use-context-menu.ts | 14 +- apps/sim/lib/workspaces/list.ts | 5 +- .../sim/lib/workspaces/seed-workspace-list.ts | 38 +++ 14 files changed, 840 insertions(+), 207 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts create mode 100644 apps/sim/app/o/[organizationId]/prefetch.test.ts create mode 100644 apps/sim/app/o/[organizationId]/prefetch.ts create mode 100644 apps/sim/lib/workspaces/seed-workspace-list.ts diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx index e4b3856a5d3..f6dd1e44e2b 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' const hoverState = vi.hoisted(() => ({ isOpen: false })) +const mockRequestJson = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) vi.mock('next/link', () => ({ default: ({ @@ -25,7 +28,7 @@ vi.mock('next/link', () => ({ ), })) -vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu', () => ({ useHoverMenu: () => ({ isOpen: hoverState.isOpen, open: vi.fn(), @@ -37,6 +40,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ })) import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section' +import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({ id: `chat-${index + 1}`, @@ -59,6 +63,8 @@ beforeEach(() => { disconnect() {} } ) + vi.clearAllMocks() + mockRequestJson.mockResolvedValue({ success: true }) hoverState.isOpen = false queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue() @@ -83,9 +89,7 @@ async function render(props: Partial[0]> = {}) { isLoading={false} isCollapsed={false} pathname={null} - menuOpenHref={null} - onContextMenu={() => {}} - onMoreClick={() => {}} + organizationId='org-1' {...props} /> @@ -94,11 +98,19 @@ async function render(props: Partial[0]> = {}) { } describe('ChatsSection', () => { - it('lists every chat with no paging control', async () => { + it('shows five chats with the workspace-style See more and See less controls', async () => { await render() - + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) + const more = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See more' + )! + await act(async () => more.click()) expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) - expect(container.textContent).not.toContain('See more') + const less = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See less' + )! + await act(async () => less.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) }) it('marks the chat on the current route active', async () => { @@ -110,17 +122,138 @@ describe('ChatsSection', () => { expect(other?.className).not.toContain('surface-active') }) - it('reports the row href when its options button is pressed', async () => { - const onMoreClick = vi.fn() - await render({ onMoreClick }) + it('keeps a bookmarked chat visible when collapsing expanded history', async () => { + await render({ pathname: CHATS[5].href }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) + expect(container.querySelector(`a[href="${CHATS[5].href}"]`)?.className).toContain( + 'surface-active' + ) + const more = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See more' + )! + await act(async () => more.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + const less = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'See less' + )! + await act(async () => less.click()) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) + expect(container.querySelector(`a[href="${CHATS[5].href}"]`)).not.toBeNull() + }) + + it('derives the visible range from the route without retaining automatic expansion', async () => { + await render({ pathname: CHATS[7].href }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + expect(container.textContent).not.toContain('See more') + expect(container.textContent).not.toContain('See less') + + await render({ pathname: null }) + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) + expect(container.textContent).toContain('See more') + }) + + it.each([false, true])('renames via the options menu with collapsed=%s', async (isCollapsed) => { + hoverState.isOpen = isCollapsed + await render({ isCollapsed }) + const button = + document.body.querySelector( + 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + ) ?? document.body.querySelector('[aria-label="Chat options"]')! + await act(async () => button.click()) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + expect(rename).toBeDefined() + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + expect(input).not.toBeNull() + expect(input.value).toMatch(/^Chat /) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Planning' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.objectContaining({ method: 'PATCH' }), + expect.objectContaining({ body: { title: 'Planning' } }) + ) + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) + + it('rolls back only the organization list when rename fails', async () => { + const pending = Promise.withResolvers<{ success: boolean }>() + mockRequestJson.mockReturnValueOnce(pending.promise) + const key = mothershipChatKeys.organizationList('org-1') + queryClient.setQueryData(key, [{ id: 'chat-1', name: 'Chat 1' }]) + const workspaceKey = mothershipChatKeys.list('workspace-1') + queryClient.setQueryData(workspaceKey, [{ id: 'workspace-chat', name: 'Workspace chat' }]) + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => action.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Pending title' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Pending title' }]) + await act(async () => pending.reject(new Error('Rename rejected'))) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Chat 1' }]) + expect(queryClient.getQueryData(workspaceKey)).toEqual([ + { id: 'workspace-chat', name: 'Workspace chat' }, + ]) + expect(input.value).toBe('Chat 1') + expect(input.disabled).toBe(false) + }) - const button = container.querySelector( - 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + it('cancels rename on Escape without a mutation', async () => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() ) - await act(async () => button?.click()) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + ) + expect(mockRequestJson).not.toHaveBeenCalled() + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) - expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2') - expect(prefetchQuery).not.toHaveBeenCalled() + it.each([ + ['Pin', { pinned: true }], + ['Mark as unread', { isUnread: true }], + ])('offers %s for organization chats', async (label, body) => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === label)! + await act(async () => action.click()) + expect(mockRequestJson).toHaveBeenCalledWith(expect.objectContaining({ method: 'PATCH' }), { + params: { chatId: 'chat-1' }, + body, + }) }) it.each([false, true])( diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 351afadfeae..d447427e017 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,19 +1,30 @@ 'use client' -import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { useState } from 'react' +import { + Chip, + ChipInput, + chipVariants, + cn, + DropdownMenuItem, + Loader, + OverflowText, + Skeleton, +} from '@sim/emcn' import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' -import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions' import { ChatNavigationLink, + CollapsedChatFlyoutItem, CollapsedSidebarMenu, SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { SIDEBAR_ITEM_GAP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' /** Stands in for a chip row while the list loads, so it carries no margin either. */ function ChatRowSkeleton() { @@ -28,11 +39,19 @@ interface ChatRowProps { chat: OrganizationChat isCurrentRoute: boolean isMenuOpen: boolean - onContextMenu: (e: React.MouseEvent, href: string) => void - onMoreClick: (e: React.MouseEvent, href: string) => void + onContextMenu: (e: React.MouseEvent, chatId: string) => void + onMorePointerDown: () => void + onMoreClick: (e: React.MouseEvent, chatId: string) => void } -function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) { +function ChatRow({ + chat, + isCurrentRoute, + isMenuOpen, + onContextMenu, + onMorePointerDown, + onMoreClick, +}: ChatRowProps) { /** * The trailing slot fits one glyph, and the dot wins over the pin: it reports * transient state (a run in progress, or an unread reply elsewhere), while pinning @@ -46,7 +65,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick chatId={chat.id} isCurrentRoute={isCurrentRoute} className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })} - onContextMenu={(e) => onContextMenu(e, chat.href)} + onContextMenu={(e) => onContextMenu(e, chat.id)} >
@@ -55,7 +74,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'size-[6px] rounded-full transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} style={{ backgroundColor: chat.isActive ? '#EAB308' : 'var(--brand-accent)' }} /> @@ -65,20 +84,21 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'absolute size-[12px] text-[var(--text-icon)] transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} /> )}
diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 331473db5b0..bcb6145d12f 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -4,18 +4,19 @@ import type { ReactNode } from 'react' import { authMockFns } from '@sim/testing' +import { dehydrate } from '@tanstack/react-query' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetOrganizationSurfaceContext, mockWorkspaceChrome, - mockPrefetchUserProfile, + mockPrefetchOrganizationSidebar, mockUseSession, } = vi.hoisted(() => ({ mockGetOrganizationSurfaceContext: vi.fn(), mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children), - mockPrefetchUserProfile: vi.fn(async () => undefined), + mockPrefetchOrganizationSidebar: vi.fn(async () => undefined), mockUseSession: vi.fn(), })) @@ -30,15 +31,15 @@ vi.mock('@/lib/auth/stale-session-recovery', () => ({ vi.mock('@tanstack/react-query', () => ({ HydrationBoundary: ({ children }: { children: ReactNode }) => children, - dehydrate: () => ({}), + dehydrate: vi.fn(() => ({})), })) vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: () => ({}), })) -vi.mock('@/lib/users/prefetch-user-profile', () => ({ - prefetchUserProfile: mockPrefetchUserProfile, +vi.mock('@/app/o/[organizationId]/prefetch', () => ({ + prefetchOrganizationSidebar: mockPrefetchOrganizationSidebar, })) vi.mock('next/headers', () => ({ @@ -80,7 +81,10 @@ const SURFACE_CONTEXT = { describe('OrganizationLayout', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } }) + mockGetSession.mockResolvedValue({ + user: { id: 'viewer-1' }, + session: { id: 'session-1', activeOrganizationId: 'active-org' }, + }) mockUseSession.mockReturnValue({ data: { user: { id: 'viewer-1' } }, isPending: false }) }) @@ -94,6 +98,7 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/login?callbackUrl=%2Fo%2Forg-1') expect(mockGetOrganizationSurfaceContext).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders the surface for a member and seeds the chrome from the collapse cookie', async () => { @@ -106,7 +111,12 @@ describe('OrganizationLayout', () => { const html = renderToStaticMarkup(element) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') - expect(mockPrefetchUserProfile).toHaveBeenCalledWith({}, 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + 'active-org' + ) expect(html).toContain('Organization child') expect(html).not.toContain('Stop impersonating') expect(mockWorkspaceChrome).toHaveBeenCalledWith( @@ -118,7 +128,7 @@ describe('OrganizationLayout', () => { it('shows the shared impersonation banner above organization content', async () => { const session = { user: { id: 'viewer-1', name: 'QA Member', email: 'member@example.com' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, } mockGetSession.mockResolvedValue(session) mockUseSession.mockReturnValue({ data: session, isPending: false }) @@ -132,6 +142,12 @@ describe('OrganizationLayout', () => { ) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + null + ) expect(html).toContain('Impersonating QA Member (member@example.com)') expect(html).toContain('Stop impersonating') expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child')) @@ -140,7 +156,7 @@ describe('OrganizationLayout', () => { it('does not use the impersonating admin to enter an organization outside the rollout', async () => { mockGetSession.mockResolvedValue({ user: { id: 'customer-member' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, }) mockGetOrganizationSurfaceContext.mockResolvedValue({ ...SURFACE_CONTEXT, @@ -158,6 +174,7 @@ describe('OrganizationLayout', () => { 'customer-member' ) expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders an explicit denial for a non-member without the surface', async () => { @@ -172,6 +189,7 @@ describe('OrganizationLayout', () => { expect(html).toContain('Organization access denied') expect(html).not.toContain('Secret organization child') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it.each(['owner', 'admin', 'member'])( @@ -190,6 +208,24 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/workspace?redirect=settings') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() } ) + + it('waits for sidebar reads before serializing hydration', async () => { + const ready = Promise.withResolvers() + mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT) + mockPrefetchOrganizationSidebar.mockReturnValue(ready.promise) + const pending = OrganizationLayout({ + children: null, + params: Promise.resolve({ organizationId: 'org-1' }), + }) + await vi.waitFor(() => expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledOnce(), { + interval: 1, + }) + expect(dehydrate).not.toHaveBeenCalled() + ready.resolve() + await pending + expect(dehydrate).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 04f5b1129b3..fb4b66e85bd 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -2,13 +2,14 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' -import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied' import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar' +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -43,16 +44,19 @@ export default async function OrganizationLayout({ const [context, cookieStore] = await Promise.all([ getOrganizationSurfaceContext(organizationId, session.user.id), cookies(), - /* The rail's footer renders the viewer, so the profile is layout data: seeded - here it paints hydrated, and a page hydrating the same key beneath finds it - populated rather than an empty query it cannot fill during render. */ - prefetchUserProfile(queryClient, session.user.id), ]) if (!context) { return } if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + await prefetchOrganizationSidebar( + queryClient, + organizationId, + { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + getActiveOrganizationId(session) + ) + const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( diff --git a/apps/sim/app/o/[organizationId]/prefetch.test.ts b/apps/sim/app/o/[organizationId]/prefetch.test.ts new file mode 100644 index 00000000000..679b8c88e24 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { dehydrate, hydrate, QueryClient, QueryObserver } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListOrganizationChats, mockListWorkspacesForViewer, mockGetUserProfile } = vi.hoisted( + () => ({ + mockListOrganizationChats: vi.fn(), + mockListWorkspacesForViewer: vi.fn(), + mockGetUserProfile: vi.fn(), + }) +) + +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + listOrganizationChats: { execute: mockListOrganizationChats }, +})) +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: mockListWorkspacesForViewer, +})) +vi.mock('@/lib/users/queries', () => ({ getUserProfile: mockGetUserProfile })) +vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' +import { userProfileKeys } from '@/hooks/queries/current-user-data' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' +import { workspaceKeys } from '@/hooks/queries/workspace' + +const PRINCIPAL: SessionPrincipal = { kind: 'session', userId: 'viewer', sessionId: 'session' } +const CHAT = { + id: 'chat', + title: 'Project notes', + updatedAt: '2026-01-02T00:00:00.000Z', + activeStreamId: null, + lastSeenAt: '2026-01-01T00:00:00.000Z', + pinned: true, + deletedAt: null, +} +const WORKSPACES = { + workspaces: [ + { + id: 'workspace', + name: 'Engineering', + ownerId: 'viewer', + organizationId: 'route-org', + workspaceMode: 'organization', + permissions: 'read', + }, + ], + lastActiveWorkspaceId: 'workspace', + pinnedWorkspaceIds: ['workspace'], + creationPolicy: null, +} +const CHAT_KEY = mothershipChatKeys.organizationList('route-org', 'active') + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function prefetch(client: QueryClient) { + return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org') +} + +describe('organization sidebar hydration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListOrganizationChats.mockResolvedValue([CHAT]) + mockListWorkspacesForViewer.mockResolvedValue(WORKSPACES) + mockGetUserProfile.mockResolvedValue({ id: 'viewer', name: 'Ada', email: 'ada@example.test' }) + }) + + it('hydrates the current viewer’s routed org chats and keeps workspace metadata intact', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + + expect(mockListOrganizationChats).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { organizationId: 'route-org', scope: 'active' }, + }) + expect(mockListWorkspacesForViewer).toHaveBeenCalledWith({ + userId: 'viewer', + activeOrganizationId: 'active-org', + scope: 'active', + }) + expect(client.getQueryData(CHAT_KEY)).toEqual([ + { + id: 'chat', + name: 'Project notes', + updatedAt: new Date(CHAT.updatedAt), + isActive: false, + isUnread: true, + isPinned: true, + deletedAt: null, + }, + ]) + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' }) + expect(client.getQueryData(mothershipChatKeys.organizationList('active-org'))).toBeUndefined() + expect(client.getQueryData(mothershipChatKeys.list('workspace'))).toBeUndefined() + expect( + client.getQueryData(mothershipChatKeys.organizationList('route-org', 'archived')) + ).toBeUndefined() + }) + + it('starts the independent reads together and waits for all before dehydration', async () => { + const chats = Promise.withResolvers<(typeof CHAT)[]>() + const workspaces = Promise.withResolvers() + mockListOrganizationChats.mockReturnValue(chats.promise) + mockListWorkspacesForViewer.mockReturnValue(workspaces.promise) + const client = makeClient() + let finished = false + const pending = prefetch(client).then(() => { + finished = true + }) + + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + expect(mockListWorkspacesForViewer).toHaveBeenCalledOnce() + expect(mockGetUserProfile).toHaveBeenCalledOnce() + expect(dehydrate(client).queries).toHaveLength(0) + expect(finished).toBe(false) + chats.resolve([CHAT]) + await chats.promise + expect(finished).toBe(false) + workspaces.resolve(WORKSPACES) + await pending + expect(dehydrate(client).queries).toHaveLength(3) + }) + + it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => { + mockListOrganizationChats.mockResolvedValue([]) + mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] }) + const client = makeClient() + await prefetch(client) + expect(client.getQueryData(CHAT_KEY)).toEqual([]) + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + }) + + it('omits a denied chat read from hydration without losing successful sidebar reads', async () => { + mockListOrganizationChats.mockRejectedValue(new Error('Forbidden')) + const server = makeClient() + await expect(prefetch(server)).resolves.toBeUndefined() + const client = makeClient() + hydrate(client, dehydrate(server)) + expect(client.getQueryState(CHAT_KEY)).toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + }) + + it('does not suppress client recovery when the workspace read fails', async () => { + mockListWorkspacesForViewer.mockRejectedValue(new Error('Unavailable')) + const client = makeClient() + await expect(prefetch(client)).resolves.toBeUndefined() + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + expect(client.getQueryData(CHAT_KEY)).toHaveLength(1) + }) + + it('does not fetch chats again when a fresh hydrated observer mounts', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + const fetchChats = vi.fn().mockResolvedValue([]) + const observer = new QueryObserver(client, { + queryKey: CHAT_KEY, + queryFn: fetchChats, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }) + const unsubscribe = observer.subscribe(() => {}) + expect(observer.getCurrentResult().isPending).toBe(false) + expect(observer.getCurrentResult().data).toHaveLength(1) + expect(fetchChats).not.toHaveBeenCalled() + unsubscribe() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/prefetch.ts b/apps/sim/app/o/[organizationId]/prefetch.ts new file mode 100644 index 00000000000..0ed2be1f62f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.ts @@ -0,0 +1,38 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { QueryClient } from '@tanstack/react-query' +import { listOrganizationChats } from '@/lib/copilot/chat/organization-chats' +import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' +import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mapChat, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' + +/** + * Settles the org sidebar's reads before hydration, using the client keys and + * mappers. Chat access goes through the same authorized operation as the API; + * failed reads stay out of hydration so the client can retry them. + */ +export async function prefetchOrganizationSidebar( + queryClient: QueryClient, + organizationId: string, + principal: SessionPrincipal, + activeOrganizationId: string | null +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), + queryFn: async () => { + const chats = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope: 'active' }, + }) + return chats.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + seedWorkspaceList(queryClient, principal.userId, activeOrganizationId), + prefetchUserProfile(queryClient, principal.userId), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 95a2735c120..45ae8b71bbe 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,14 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import type { QueryClient } from '@tanstack/react-query' -import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' -import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' +import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, @@ -17,7 +15,6 @@ import { } from '@/hooks/queries/mothership-chats' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' -import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' import { WORKSPACE_HOST_CONTEXT_STALE_TIME, @@ -40,51 +37,6 @@ export function prefetchWorkspaceHostContext( }) } -const logger = createLogger('WorkspacePrefetch') - -/** - * Seeds the viewer's workspace list, which the switcher reads. - * - * Seeded rather than prefetched so the empty-list case can decline to create a - * cache entry at all: the route's default-workspace creation path must run on - * the client, and an entry — even an empty one — would suppress it. Expressing - * that as an absent seed also keeps a routine state out of the error channel, - * where it read as a failure rather than as "nothing to seed". - */ -async function seedWorkspaceList( - queryClient: QueryClient, - userId: string, - activeOrganizationId: string | null -): Promise { - try { - const payload = await listWorkspacesForViewer({ - userId, - activeOrganizationId, - scope: 'active', - }) - if (payload.workspaces.length === 0) return - /** - * Parsing through the route contract's response schema strips the same - * server-only fields `requestJson` strips on the client, guaranteeing the - * seeded shape is identical to a client fetch. - */ - queryClient.setQueryData( - workspaceKeys.list('active'), - normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) - ) - } catch (error) { - /** - * Swallowed rather than rethrown — this read is an optimization; the layout - * renders fine without it and the client fetch reaches the route instead. - * Logged because contract drift between the read and the response schema - * would otherwise degrade silently into every viewer waterfalling. - */ - logger.warn('Workspace list seed failed; client will fetch', { - error: getErrorMessage(error), - }) - } -} - /** * Prefetches the sidebar's workflow, chat, folder, workspace-permissions, * workspace, and viewer-profile reads for a workspace and stores them under the diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx index 2b73e2dc4a7..b4292375be0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.test.tsx @@ -84,6 +84,66 @@ afterEach(() => { }) describe('sidebar context menu dismissal', () => { + it('keeps the exiting menu inert after handing focus to the rename input', () => { + const getComputedStyle = window.getComputedStyle + /** JSDOM snapshots styles; Radix Presence requires a live exit-animation name. */ + const animationStyles = vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const styles = getComputedStyle(element) + if (element.getAttribute('role') === 'menu') { + Object.defineProperty(styles, 'animationName', { + get: () => (element.getAttribute('data-state') === 'closed' ? 'menu-exit' : 'menu-enter'), + }) + } + return styles + }) + const menuRef = { current: null as HTMLDivElement | null } + const renameInputRef = { current: null as HTMLInputElement | null } + const onRenameBlur = vi.fn() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + function renderRenameMenu(isOpen: boolean) { + root?.render( + <> + + renderRenameMenu(false)} + onRename={() => renameInputRef.current?.focus()} + renameInputRef={renameInputRef} + showDelete={false} + showDuplicate={false} + /> + + ) + } + + try { + act(() => renderRenameMenu(true)) + const menu = menuRef.current! + const renameItem = menu.querySelector('[role="menuitem"]')! + expect(menu.hasAttribute('inert')).toBe(false) + const pointerMove = new MouseEvent('pointermove', { bubbles: true, cancelable: true }) + Object.defineProperty(pointerMove, 'pointerType', { value: 'mouse' }) + act(() => renameItem.dispatchEvent(pointerMove)) + expect(document.activeElement).toBe(renameItem) + + act(() => renameItem.click()) + + expect(menu.isConnected).toBe(true) + expect(menu.getAttribute('data-state')).toBe('closed') + expect(menu.hasAttribute('inert')).toBe(true) + expect(document.activeElement).toBe(renameInputRef.current) + expect(onRenameBlur).not.toHaveBeenCalled() + } finally { + animationStyles.mockRestore() + } + }) + it('stays open when a surrounding menu takes focus back', () => { const onClose = vi.fn() renderMenu(onClose) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 447b69008e2..20fa333126e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -33,6 +33,7 @@ interface ContextMenuProps { position: { x: number; y: number } menuRef: React.RefObject onClose: () => void + onCopyLink?: () => void onOpenInNewTab?: () => void openInNewTabLabel?: string openInNewTabPosition?: 'first' | 'last' @@ -56,7 +57,7 @@ interface ContextMenuProps { onCreateFolder?: () => void onDuplicate?: () => void onExport?: () => void - onDelete: () => void + onDelete?: () => void /** * Closes the item rather than deleting it — for tabs, where the destructive * action is "close this one", not "delete it forever". Named for the item so @@ -118,6 +119,7 @@ export function ContextMenu({ position, menuRef, onClose, + onCopyLink, onOpenInNewTab, openInNewTabLabel = 'Open in new tab', openInNewTabPosition = 'first', @@ -169,6 +171,7 @@ export function ContextMenu({ selectedCount = 1, }: ContextMenuProps) { const hasActionsAboveDestructive = + onCopyLink || (showOpenInNewTab && onOpenInNewTab) || (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) || @@ -182,7 +185,7 @@ export function ContextMenu({ (showExport && onExport) const hasDestructiveSection = (showLeave && onLeave) || - showDelete || + (showDelete && onDelete) || (showCloseTab && onCloseTab) || onCloseOtherTabs || onCloseTabsToRight @@ -214,6 +217,7 @@ export function ContextMenu({ side='bottom' sideOffset={4} className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]' + inert={!isOpen} onFocusOutside={(e) => { const target = e.target if (target instanceof Element && target.closest('[role="menu"]')) { @@ -242,6 +246,17 @@ export function ContextMenu({ {openInNewTabLabel} )} + {onCopyLink && ( + { + onCopyLink() + onClose() + }} + > + + Copy link + + )} {showMarkAsRead && onMarkAsRead && ( )} - {showDelete && ( + {showDelete && onDelete && ( { diff --git a/apps/sim/hooks/use-context-menu.ts b/apps/sim/hooks/use-context-menu.ts index d87a3258d4e..75fb0393a0a 100644 --- a/apps/sim/hooks/use-context-menu.ts +++ b/apps/sim/hooks/use-context-menu.ts @@ -29,20 +29,21 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { const menuRef = useRef(null) const dismissPreventedRef = useRef(false) + const openMenuAt = useCallback((nextPosition: ContextMenuPosition) => { + setPosition(nextPosition) + setIsOpen(true) + }, []) + const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - const x = e.clientX - const y = e.clientY - - setPosition({ x, y }) - setIsOpen(true) + openMenuAt({ x: e.clientX, y: e.clientY }) onContextMenu?.(e) }, - [onContextMenu] + [onContextMenu, openMenuAt] ) const closeMenu = useCallback(() => { @@ -84,6 +85,7 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { position, menuRef, handleContextMenu, + openMenuAt, closeMenu, preventDismiss, } diff --git a/apps/sim/lib/workspaces/list.ts b/apps/sim/lib/workspaces/list.ts index bba77f0e6a3..163276dc6ff 100644 --- a/apps/sim/lib/workspaces/list.ts +++ b/apps/sim/lib/workspaces/list.ts @@ -112,9 +112,8 @@ async function buildWorkspacesWithInviteFlags( * the workspace creation policy. * * Unlike the route, this performs no writes — no default-workspace creation and - * no orphaned-workflow repair. It exists for the workspace layout's sidebar - * prefetch, which only runs after host-context authorization has proven the - * viewer already has at least one accessible workspace. + * no orphaned-workflow repair. Sidebar prefetch leaves empty lists uncached so + * the client can still reach the route's default-workspace creation path. */ export async function listWorkspacesForViewer(params: { userId: string diff --git a/apps/sim/lib/workspaces/seed-workspace-list.ts b/apps/sim/lib/workspaces/seed-workspace-list.ts new file mode 100644 index 00000000000..b9a1c6064d7 --- /dev/null +++ b/apps/sim/lib/workspaces/seed-workspace-list.ts @@ -0,0 +1,38 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { QueryClient } from '@tanstack/react-query' +import { listWorkspacesContract } from '@/lib/api/contracts/workspaces' +import { listWorkspacesForViewer } from '@/lib/workspaces/list' +import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' +import { workspaceKeys } from '@/hooks/queries/workspace' + +const logger = createLogger('WorkspaceListPrefetch') + +/** + * Leaves empty workspace lists uncached so the client reaches the route's + * default-workspace creation path. + */ +export async function seedWorkspaceList( + queryClient: QueryClient, + userId: string, + activeOrganizationId: string | null +): Promise { + try { + const payload = await listWorkspacesForViewer({ + userId, + activeOrganizationId, + scope: 'active', + }) + if (payload.workspaces.length === 0) return + /** Strip server-only fields to match the client response. */ + queryClient.setQueryData( + workspaceKeys.list('active'), + normalizeWorkspacesResponse(listWorkspacesContract.response.schema.parse(payload)) + ) + } catch (error) { + /** Keep optional prefetch failures from blocking the layout. */ + logger.warn('Workspace list seed failed; client will fetch', { + error: getErrorMessage(error), + }) + } +} From f5e28a1d490c5ae8845721e45eaa61e5d8cfa438 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 9 Sep 2026 16:23:09 -0700 Subject: [PATCH 04/30] fix(organizations): show all sidebar chats (#7697) --- .../chats-section/chats-section.test.tsx | 48 ++----------- .../chats-section/chats-section.tsx | 70 +++++++------------ 2 files changed, 30 insertions(+), 88 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx index f6dd1e44e2b..e68e55f599c 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -98,60 +98,22 @@ async function render(props: Partial[0]> = {}) { } describe('ChatsSection', () => { - it('shows five chats with the workspace-style See more and See less controls', async () => { + it('shows all chats without pagination controls', async () => { await render() - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) - const more = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'See more' - )! - await act(async () => more.click()) expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) - const less = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'See less' - )! - await act(async () => less.click()) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) + expect(container.textContent).not.toContain('See more') + expect(container.textContent).not.toContain('See less') }) it('marks the chat on the current route active', async () => { - await render({ pathname: '/o/org-1/chat/chat-3' }) + await render({ pathname: '/o/org-1/chat/chat-8' }) - const current = container.querySelector('a[href="/o/org-1/chat/chat-3"]') + const current = container.querySelector('a[href="/o/org-1/chat/chat-8"]') const other = container.querySelector('a[href="/o/org-1/chat/chat-4"]') expect(current?.className).toContain('surface-active') expect(other?.className).not.toContain('surface-active') }) - it('keeps a bookmarked chat visible when collapsing expanded history', async () => { - await render({ pathname: CHATS[5].href }) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) - expect(container.querySelector(`a[href="${CHATS[5].href}"]`)?.className).toContain( - 'surface-active' - ) - const more = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'See more' - )! - await act(async () => more.click()) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) - const less = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'See less' - )! - await act(async () => less.click()) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(6) - expect(container.querySelector(`a[href="${CHATS[5].href}"]`)).not.toBeNull() - }) - - it('derives the visible range from the route without retaining automatic expansion', async () => { - await render({ pathname: CHATS[7].href }) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) - expect(container.textContent).not.toContain('See more') - expect(container.textContent).not.toContain('See less') - - await render({ pathname: null }) - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(5) - expect(container.textContent).toContain('See more') - }) - it.each([false, true])('renames via the options menu with collapsed=%s', async (isCollapsed) => { hoverState.isOpen = isCollapsed await render({ isCollapsed }) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index d447427e017..df2b36b4aa9 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,8 +1,6 @@ 'use client' -import { useState } from 'react' import { - Chip, ChipInput, chipVariants, cn, @@ -117,8 +115,6 @@ interface ChatsSectionProps { pathname: string | null } -const PAGE_SIZE = 5 - export function ChatsSection({ organizationId, chats, @@ -128,10 +124,6 @@ export function ChatsSection({ }: ChatsSectionProps) { const actions = useOrganizationChatActions({ organizationId, chats }) const { menu, hover, rename, selectedChat } = actions - const [requestedCount, setRequestedCount] = useState(PAGE_SIZE) - const minimumCount = Math.max(PAGE_SIZE, chats.findIndex((chat) => chat.href === pathname) + 1) - const visibleCount = Math.min(chats.length, Math.max(requestedCount, minimumCount)) - const hasMore = chats.length > visibleCount const menuOpenChatId = menu.isOpen ? selectedChat?.id : null const saveRename = () => { void rename.saveRename() @@ -192,43 +184,31 @@ export function ChatsSection({ No chats yet )} - {chats - .slice(0, visibleCount) - .map((chat) => - rename.editingId === chat.id ? ( - rename.setValue(event.target.value)} - onKeyDown={rename.handleKeyDown} - onBlur={saveRename} - disabled={rename.isSaving} - maxLength={100} - autoComplete='off' - /> - ) : ( - - ) - )} - {(hasMore || visibleCount > minimumCount) && ( - - setRequestedCount(hasMore ? visibleCount + PAGE_SIZE : PAGE_SIZE) - } - > - {hasMore ? 'See more' : 'See less'} - + {chats.map((chat) => + rename.editingId === chat.id ? ( + rename.setValue(event.target.value)} + onKeyDown={rename.handleKeyDown} + onBlur={saveRename} + disabled={rename.isSaving} + maxLength={100} + autoComplete='off' + /> + ) : ( + + ) )} )} From 54c3392e625a8a6b760dbb50e7b67e1ee4fb0de9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 16:45:21 -0700 Subject: [PATCH 05/30] feat(search): add GitHub installation indexing (#7694) * feat(search): add GitHub installation indexing * fix(search): scope GitHub access checks to requested results * fix(search): clarify public setup documentation --- .../self-hosting/integrations-oauth.mdx | 96 +++- .../docs/search/connect-your-account.mdx | 1 + apps/docs/content/docs/search/github.mdx | 125 ++--- apps/docs/content/docs/search/index.mdx | 9 +- apps/sim/.env.example | 11 + .../github/installations/route.test.ts | 223 ++++++++ .../knowledge/github/installations/route.ts | 53 ++ .../[connectorType]/provider-detail.test.tsx | 1 + .../add-connector-modal.test.tsx | 57 ++- .../add-connector-modal.tsx | 32 +- .../github-installation-modal.test.tsx | 199 ++++++++ .../components/github-installation-modal.tsx | 220 ++++++++ .../components/search-source-setup.test.tsx | 2 +- apps/sim/connectors/github/github.test.ts | 48 ++ apps/sim/connectors/github/github.ts | 34 +- .../organization-account-providers.test.tsx | 113 +++- .../organization-account-providers.tsx | 54 +- .../github-search-installations.test.tsx | 114 +++++ .../queries/github-search-installations.ts | 53 ++ .../knowledge/github-installations.test.ts | 48 ++ .../knowledge/github-installations.ts | 79 +++ apps/sim/lib/core/config/env.ts | 3 + .../application/provider-catalog.ts | 11 + .../lib/credentials/service-account-secret.ts | 6 + .../integrations/credential-display.test.ts | 1 + .../credential-visibility.server.ts | 10 + .../github-member.integration.ts | 398 ++++++++++++++- .../access/github-installation.test.ts | 305 +++++++++++ .../knowledge/access/github-installation.ts | 317 ++++++++++++ .../access/predicate.postgres.test.ts | 119 ++++- apps/sim/lib/knowledge/access/predicate.ts | 96 ++++ apps/sim/lib/knowledge/access/scope.test.ts | 46 +- apps/sim/lib/knowledge/access/scope.ts | 129 ++++- apps/sim/lib/knowledge/access/types.ts | 21 + .../knowledge/application/connector-access.ts | 16 +- .../lib/knowledge/application/connectors.ts | 26 +- .../knowledge/application/contexts.test.ts | 24 + .../sim/lib/knowledge/application/contexts.ts | 84 ++- .../knowledge/application/documents.test.ts | 6 +- .../lib/knowledge/application/documents.ts | 53 +- .../github-installation-source.test.ts | 153 ++++++ .../application/github-installation-source.ts | 84 +++ .../github-installations.postgres.test.ts | 144 ++++++ .../application/github-installations.test.ts | 208 ++++++++ .../application/github-installations.ts | 242 +++++++++ .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 18 + .../application/read-indexed-document.ts | 34 +- .../application/search-source-overview.ts | 123 +++-- .../application/search-source-progress.ts | 4 +- .../application/search-sources.test.ts | 7 +- .../knowledge/application/search-sources.ts | 6 +- .../lib/knowledge/application/search.test.ts | 7 +- apps/sim/lib/knowledge/application/search.ts | 18 +- .../application/slack-search/source-status.ts | 55 +- apps/sim/lib/knowledge/application/tags.ts | 4 +- .../lib/knowledge/chunks/keyset-sql.test.ts | 3 +- .../knowledge/connectors/access-token.test.ts | 18 + .../lib/knowledge/connectors/access-token.ts | 16 +- .../lib/knowledge/documents/service.test.ts | 54 ++ apps/sim/lib/knowledge/documents/service.ts | 265 ++++++---- .../orchestration/connector-access.ts | 10 + .../lib/knowledge/orchestration/connectors.ts | 11 +- apps/sim/lib/knowledge/read-access.test.ts | 75 +++ apps/sim/lib/knowledge/read-access.ts | 65 +++ apps/sim/lib/knowledge/search/queries.test.ts | 157 +++++- apps/sim/lib/knowledge/search/queries.ts | 291 ++++++++++- apps/sim/lib/knowledge/tags/service.ts | 95 ++-- apps/sim/lib/oauth/credential-service.ts | 45 ++ .../github-installation-credential.test.ts | 99 ++++ .../lib/oauth/github-installation-types.ts | 23 + .../sim/lib/oauth/github-installation.test.ts | 331 ++++++++++++ apps/sim/lib/oauth/github-installation.ts | 481 ++++++++++++++++++ apps/sim/lib/oauth/github-repository.test.ts | 42 ++ apps/sim/lib/oauth/github-repository.ts | 19 + apps/sim/lib/oauth/oauth.ts | 2 + ...check-tool-registry-boundary.baseline.json | 14 +- 77 files changed, 5973 insertions(+), 495 deletions(-) create mode 100644 apps/sim/app/api/knowledge/github/installations/route.test.ts create mode 100644 apps/sim/app/api/knowledge/github/installations/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.tsx create mode 100644 apps/sim/hooks/queries/github-search-installations.test.tsx create mode 100644 apps/sim/hooks/queries/github-search-installations.ts create mode 100644 apps/sim/lib/api/contracts/knowledge/github-installations.test.ts create mode 100644 apps/sim/lib/api/contracts/knowledge/github-installations.ts create mode 100644 apps/sim/lib/knowledge/access/github-installation.test.ts create mode 100644 apps/sim/lib/knowledge/access/github-installation.ts create mode 100644 apps/sim/lib/knowledge/application/github-installation-source.test.ts create mode 100644 apps/sim/lib/knowledge/application/github-installation-source.ts create mode 100644 apps/sim/lib/knowledge/application/github-installations.postgres.test.ts create mode 100644 apps/sim/lib/knowledge/application/github-installations.test.ts create mode 100644 apps/sim/lib/knowledge/application/github-installations.ts create mode 100644 apps/sim/lib/knowledge/documents/service.test.ts create mode 100644 apps/sim/lib/knowledge/read-access.test.ts create mode 100644 apps/sim/lib/knowledge/read-access.ts create mode 100644 apps/sim/lib/oauth/github-installation-credential.test.ts create mode 100644 apps/sim/lib/oauth/github-installation-types.ts create mode 100644 apps/sim/lib/oauth/github-installation.test.ts create mode 100644 apps/sim/lib/oauth/github-installation.ts create mode 100644 apps/sim/lib/oauth/github-repository.test.ts create mode 100644 apps/sim/lib/oauth/github-repository.ts diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index 6842f52e618..acc1fa6c884 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -6,6 +6,7 @@ description: Register OAuth apps so your users can connect Slack, Google, Jira, import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { FAQ } from '@/components/ui/faq' +import { Image } from '@/components/ui/image' **OAuth integrations need your own provider application on a self-hosted deployment.** Configure the OAuth services your team uses; API-key integrations can instead use keys supplied in their blocks. Users will see the connector in the UI, click "Connect", and get an error from the provider until the corresponding `*_CLIENT_ID` and `*_CLIENT_SECRET` are set. @@ -116,15 +117,100 @@ The same variables also power "Sign in with Microsoft". ### GitHub Search -Register a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with repository **Contents: read-only**, **Metadata: read-only**, and account **Email addresses: read-only** permissions. Keep user access token expiration enabled so Sim can rotate access and refresh tokens. +Self-hosted GitHub Search uses a GitHub App for account connections and organization installation indexing. Register your own App and configure the server variables below. Sim Cloud users use the [GitHub Search setup flow](/search/github#add-a-repository) directly. -| Environment variables | Provider ID | + + + +#### Register the App + +For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. + +Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. + +Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: + +```text +/api/auth/oauth2/callback/github-repositories +``` + +Replace `` with your configured public origin, such as `https://sim.example.com`, without a trailing slash. The scheme, hostname, port, and path must match exactly; `www` and non-`www` hosts are different. See GitHub's [callback matching rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). + +| GitHub setting | Value for Sim Search | |---|---| -| `GITHUB_APP_CLIENT_ID`
`GITHUB_APP_CLIENT_SECRET` | `github-repositories` | +| Allow wildcard matching | Disabled | +| Expire user authorization tokens | Enabled | +| Request user authorization (OAuth) during installation | Disabled | +| Enable Device Flow | Disabled | +| Post installation → Setup URL | Empty | +| Webhook → Active | Disabled | + +Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. + +GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled + +*Example registration. Replace `sim.example.com` with your Sim domain.* + +
+ + +#### Set read permissions + +Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**. + +GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only + +Expand **Account permissions** and set **Email addresses → Access: Read-only**. + +GitHub account permissions with only Email addresses selected for Read-only access + +| Permission area | Permission | Access | +|---|---|---| +| Repository | Contents | Read-only | +| Repository | Metadata | Read-only | +| Account | Email addresses | Read-only | + +Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings. + +GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). + +Set **Where can this GitHub App be installed? → Any account** to support connections from accounts outside the App owner. This lets any GitHub account install and authorize the App, subject to that account's organization policies. Making the App public does not make repositories public or grant anyone Search access. See GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private). + +Select **Create GitHub App**. + + + + +#### Configure Sim + +On the App's **General** settings page, copy its numeric **App ID** and **Client ID**, select **Generate a new client secret**, and generate a **Private key**. The App slug is the final part of its public URL: `https://github.com/apps/`. + +Set all five variables using values from your GitHub App, then restart Sim: + +```text +GITHUB_APP_ID= +GITHUB_APP_SLUG= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +GITHUB_APP_PRIVATE_KEY= +``` + +The private key must include its PEM header, footer, and contents. Sim accepts actual newlines or escaped `\n` sequences. Keep the private key and client secret in the deployment's server configuration; organization admins select installations in Sim without entering these secrets. + +The **Client ID** is different from the numeric **App ID**. Use credentials from **Developer settings → GitHub Apps**. `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` belong to the separate GitHub sign-in integration and remain unchanged. Search does not read `GITHUB_REPO_CLIENT_ID` or `GITHUB_REPO_CLIENT_SECRET`. + +Keep **Expire user authorization tokens** enabled so Sim receives the refresh token it needs to [renew personal connections](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). + +Complete the installation through [the GitHub Search source setup](/search/github#add-a-repository). + +If you replace a deployment's GitHub App, an organization admin must first open **Settings → Connected accounts → Providers → Update configurations**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. + + +
-Register `https:///api/auth/oauth2/callback/github-repositories` as the callback. These App OAuth client credentials are separate from `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` used for Sim sign-in. Sim does not require an App private key. +If GitHub rejects `redirect_uri`, compare the App's registered callback with `NEXT_PUBLIC_APP_URL` followed by `/api/auth/oauth2/callback/github-repositories`. Keep wildcard matching disabled. If installation indexing is unavailable, confirm all five `GITHUB_APP_*` variables belong to the same App and include a complete RSA private key. -A repository or organization administrator installs the App on the repositories to search. Each member connects their own GitHub account, with a verified email matching their Sim account. Search indexes repository files that both the member and the installed App can access. GitHub workflow blocks and existing knowledge-base token connections continue to use personal access tokens. +GitHub workflow blocks and knowledge-base token connections continue to use personal access tokens. ### Everything else diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 26508b493a7..025f7414568 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -56,6 +56,7 @@ For a source configured inside a workspace, join that workspace and use its **Se | Source setup | Your next step | | --- | --- | | Member accounts | Connect your own account, including when you are the admin. | +| GitHub App installation | Connect GitHub once for this Sim organization. The App handles indexing; your account establishes which repositories you may search. | | Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. | | Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. | | GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. | diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 1bcc37159b6..023068d0962 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -1,117 +1,55 @@ --- title: GitHub -description: Search repository files through each member's GitHub account +description: Index repository files with a GitHub App while preserving each person's access --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' -import { Image } from '@/components/ui/image' -GitHub Search indexes text files from a repository on `github.com`. An organization admin chooses the repository, then each person connects their GitHub account. Installing the GitHub App alone does not connect your teammates. +GitHub Search indexes text files from repositories on `github.com`. An organization admin can install the GitHub App once and use it to index selected repositories. Each person connects their own GitHub account once to search the repositories they can access. Installing the App does not connect teammates or give them the installer's permissions. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account; **Create & Invite** is the workspace equivalent of **Add source**. ## Before you start -Your Sim deployment needs a GitHub App configured as described below. The repository must contain at least one commit; initialize an empty repository with a README before adding it. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. +The repository must contain at least one commit. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. -## Configure the GitHub App +On Sim Cloud, connect through the Sim Search GitHub App in the setup flow below. If you self-host Sim, a deployment administrator must first [configure GitHub Search](/platform/self-hosting/integrations-oauth#github-search). -This step belongs to the Sim deployment administrator. If the App is already configured, continue to [Add a repository](#add-a-repository). +To connect an installation for central indexing, you must be a Sim organization admin and either own the GitHub personal account or be an owner of the GitHub organization where the App is installed. You must also be able to read the repository you add. - - - -### Register the App - -For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. - -Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. - -Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: - -```text -https:///api/auth/oauth2/callback/github-repositories -``` - -| GitHub setting | Value for Sim Search | -|---|---| -| Allow wildcard matching | Disabled | -| Expire user authorization tokens | Enabled | -| Request user authorization (OAuth) during installation | Disabled | -| Enable Device Flow | Disabled | -| Post installation → Setup URL | Empty | -| Webhook → Active | Disabled | - -Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. - -GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled - -*Example registration. Replace `sim.example.com` with your Sim domain.* +## Add a repository - + -### Set read permissions - -Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**. - -GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only - -Expand **Account permissions** and set **Email addresses → Access: Read-only**. - -GitHub account permissions with only Email addresses selected for Read-only access - -| Permission area | Permission | Access | -|---|---|---| -| Repository | Contents | Read-only | -| Repository | Metadata | Read-only | -| Account | Email addresses | Read-only | - -Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings. - -GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). - -Under **Where can this GitHub App be installed?**, choose **Only on this account** for an organization-owned App used only by members of that organization. Choose **Any account** when teammates or repository owners are outside that organization, or the App is owned by your personal account. A private App owned by a personal account can only be authorized by its owner; see GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private). +### Open GitHub setup -Select **Create GitHub App**. +Open **Settings → Sources** and turn on **GitHub** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. -### Configure Sim and install the App +### Choose how to index -On the App's **General** settings page, copy its **Client ID**, then select **Generate a new client secret**. Configure these deployment variables and restart Sim: +In **Sync documents with**, choose **Connect GitHub App** to index through an installation: -```text -GITHUB_APP_CLIENT_ID= -GITHUB_APP_CLIENT_SECRET= -``` +1. Select **Connect your GitHub account** if prompted. Finish authorization in the new tab, then return and select **Refresh**. +2. Select **Install GitHub App**. Choose your GitHub account or organization and the repositories to include. If it is already installed, check its repository selection. +3. Return to Sim and select **Refresh**. Choose the installation and select **Use installation**. Only installations on your own account or organizations you own are available. -Use the **Client ID** and **client secret** from **Developer settings → GitHub Apps**. OAuth App credentials used for GitHub sign-in are not compatible. The numeric **App ID** and downloaded private key are not used by this connector. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). +The installation is now selected under **Sync documents with**. You can reuse it when adding another repository source in the same Sim organization. -In the App's sidebar, choose **Install App**, select the target account, and grant access to the repositories you want to search. Return to Sim and select **Connect account**. Every teammate must authorize from Sim too. - - - - -## Add a repository - - - - -### Open GitHub setup - -Open **Settings → Sources** and turn on **GitHub** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. +Alternatively, leave **Connected members** selected to use members' accounts for indexing, or select an existing dedicated account. Each method still requires teammates to connect their own accounts for Search access. ### Choose what to index -Enter **Repository** as `owner/repo`. Open **More options** only if you need a different branch, path or extension filters, metadata tags, or a dedicated indexing account. **Sync documents with** defaults to **Connected members**. +Enter **Repository** as `owner/repo`. For installation indexing, it must belong to the installation's account and be included in the repositories granted to the App. Add one source per repository; installing on all repositories does not automatically create sources for them. -GitHub source setup with a required Repository field and More options +Open **More options** if you need a different branch, path or extension filters, or metadata tags. | Field | What to enter | |---|---| @@ -122,8 +60,6 @@ Enter **Repository** as `owner/repo`. Open **More options** only if you need a d **Metadata tags** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source. -You can instead select an existing account under **Sync documents with** to supply file contents centrally. Teammates still connect their own accounts to establish which files they may find. - @@ -131,24 +67,37 @@ You can instead select an existing account under **Sync documents with** to supp Open **Integrations** in the main sidebar, select **Connect account** on the GitHub source, and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). -With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. +An App installation or dedicated indexing account can start syncing after the source is saved. With **Connected members**, indexing begins after someone connects. Each teammate still connects before searching. An existing GitHub connection in the same Sim organization is reused across its GitHub sources. + +Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also use **Add another GitHub source** in the main Integrations page. +## How access is enforced + +GitHub App installation access supplies file contents for indexing. Each reader's own connected GitHub account determines which repository's indexed content they can search. Sim organization admins follow the same rule as other readers. + +For installation-indexed sources, Sim checks the installation's current status and verifies repository content access with the reader's GitHub account before returning results or opening indexed content. If GitHub cannot confirm access, that repository's content is withheld. Removing a person's repository access, disconnecting their account, or removing the repository from the App's access prevents subsequent reads once GitHub reflects the change. File edits still appear after background indexing. + +This is an installation plus personal authorization flow. GitHub Search does not impersonate everyone in an email domain. [Google Drive delegation and GitLab administrator indexing](/search#choose-the-right-connection-method) use different supported identity and permission models. + ## Troubleshooting | Problem | Next step | |---|---| -| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. | -| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). | -| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. | +| GitHub is unavailable in Search | Ask your Sim organization admin to enable GitHub under **Settings → Sources**. For self-hosted Sim, also check the [GitHub App configuration](/platform/self-hosting/integrations-oauth#github-search). | | Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. | -| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. | +| A teammate cannot authorize the App | Check the GitHub organization's App policies and required approvals. Self-hosted deployments must allow the teammate's account in their App visibility settings. | +| No eligible installations found | Finish connecting your own GitHub account, install the configured App on your account or an organization you own, then select **Refresh**. An installation of a different App or one you only have repository access to cannot be selected. | +| Repository is not accepted for an installation | Check `owner/repo`, the installation's account and repository selection, and your own access. Update the source's Repository field after a rename. After a transfer, add a source using an installation for the new owner. | | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | | Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | +| Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | +| Update GitHub in Connected accounts before connecting this source | An organization admin must select **Settings → Connected accounts → Providers → Update configurations**, then reconnect GitHub. | +| Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | | Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | | Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index 3673675951a..cfbd2812709 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -45,17 +45,18 @@ Source availability depends on the deployment and organization policy. An unavai ## Choose the right connection method -Most sources use member accounts. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. +Most sources use member accounts. GitHub supports a central App installation with a personal connection for each reader. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. | Method | What the admin does | What teammates do | | --- | --- | --- | | **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. | +| **GitHub App installation** | Installs the App, selects it under **Sync documents with**, and adds repository sources. | Connect GitHub once. Sim checks each reader's current repository access before returning installation-indexed content. | | **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | | **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | Adding a Google Drive or Confluence source from the admin page starts central setup. For personal connections, use **Integrations → Connect account** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. -Some member sources offer **More options → Sync documents with**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. +Some member sources offer **Sync documents with**, either directly in setup or under **More options**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. For GitHub organization sources, choose **Connect GitHub App** in this field to [connect an installation](/search/github#add-a-repository). **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. It also does not automatically discover every employee's data: the indexing account must be able to read the configured content. @@ -66,7 +67,7 @@ Some member sources offer **More options → Sync documents with**. **Connected | Source | Content | Connection in Search | | --- | --- | --- | | [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects | -| [GitHub](/search/github) | Repository text files | GitHub App installation plus each member's authorization | +| [GitHub](/search/github) | Repository text files | App installation or member indexing; each teammate connects | | [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection | | [Gmail](/search/gmail) | Email thread text | Each member's Gmail account | | [Google Calendar](/search/google-calendar) | Calendar events | Each member's Google Calendar account | @@ -133,7 +134,7 @@ Workspace Search remains separate. Workspace admins add sources through **Search 3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them. 4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh. -Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. +Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits appear after syncing. Permission refresh behavior depends on the connector: GitHub sources indexed through an App installation also check the reader's current repository access before returning indexed content. ## If indexing needs attention diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f00c68f8094..4f2b7a58cee 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -143,6 +143,17 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # S3_ENDPOINT= # Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3 # S3_FORCE_PATH_STYLE=true # Required for MinIO/Ceph RGW. Leave unset for AWS S3 and R2 +# GitHub Search (Optional - credentials from a GitHub App with expiring user tokens) +# Use a separate app for production and staging. Allow installation by any account for a public app. +# Grant repository Contents: read and Metadata: read, plus user Email addresses: read. +# Callback: /api/auth/oauth2/callback/github-repositories +# GITHUB_APP_CLIENT_ID= # GitHub App client ID; distinct from sign-in OAuth credentials +# GITHUB_APP_CLIENT_SECRET= +# Optional organization indexing through an app installation; readers still connect their own GitHub account. +# GITHUB_APP_ID= # Numeric GitHub App ID +# GITHUB_APP_SLUG= # App slug from https://github.com/apps/ +# GITHUB_APP_PRIVATE_KEY= # RSA PEM private key; literal \\n sequences are accepted + # Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login) # INSTAGRAM_CLIENT_ID= # INSTAGRAM_CLIENT_SECRET= diff --git a/apps/sim/app/api/knowledge/github/installations/route.test.ts b/apps/sim/app/api/knowledge/github/installations/route.test.ts new file mode 100644 index 00000000000..1352bd78276 --- /dev/null +++ b/apps/sim/app/api/knowledge/github/installations/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), connect: vi.fn(), rateLimit: vi.fn() })) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mocks.rateLimit, + RateLimiter: class {}, +})) +vi.mock('@/lib/knowledge/application/github-installations', () => ({ + listGitHubSearchInstallations: { + operation: { id: 'knowledge.github.installations.list' }, + execute: mocks.list, + }, + connectGitHubSearchInstallation: { + operation: { id: 'knowledge.github.installations.connect' }, + execute: mocks.connect, + }, +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message) + } + }, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + ManagedOAuthCredentialError: class extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode: number + ) { + super(message) + } + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' +import { GET, POST } from '@/app/api/knowledge/github/installations/route' + +const URL = 'http://localhost/api/knowledge/github/installations' +const installation = { + installationId: '123', + accountId: '456', + accountLogin: 'acme', + accountType: 'Organization', +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.rateLimit.mockResolvedValue(null) + mocks.list.mockResolvedValue({ + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [installation], + }) + mocks.connect.mockResolvedValue({ credential: { id: 'cred-1', displayName: 'GitHub · acme' } }) +}) + +describe('GitHub installation route boundary', () => { + it.each(['GET', 'POST'] as const)( + 'authenticates %s before parsing or calling the use case', + async (method) => { + authMockFns.mockGetSession.mockResolvedValue(null) + const request = new NextRequest(URL, method === 'POST' ? { method, body: '{' } : undefined) + const json = vi.spyOn(request, 'json') + const response = await (method === 'GET' ? GET(request) : POST(request)) + expect(response.status).toBe(401) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(json).not.toHaveBeenCalled() + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() + expect(mocks.connect).not.toHaveBeenCalled() + } + ) + + it('applies admission before parsing the POST body', async () => { + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + ) + const request = new NextRequest(URL, { method: 'POST', body: '{' }) + const json = vi.spyOn(request, 'json') + expect((await POST(request)).status).toBe(429) + expect(json).not.toHaveBeenCalled() + expect(mocks.connect).not.toHaveBeenCalled() + expect(mocks.rateLimit).toHaveBeenCalledWith( + 'github-search-installations', + 'admin-1', + undefined + ) + }) + + it.each(['0', '-1', '1.5', '123/path', ''])( + 'rejects invalid installation ID %s before the use case', + async (installationId) => { + const response = await POST( + new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1', installationId }), + }) + ) + expect(response.status).toBe(400) + expect(mocks.connect).not.toHaveBeenCalled() + } + ) + + it('requires organization scope for GET', async () => { + expect((await GET(new NextRequest(URL))).status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('forwards GET identity and cancellation and projects a private installation list', async () => { + const controller = new AbortController() + const request = new NextRequest(`${URL}?organizationId=org-1`, { signal: controller.signal }) + mocks.list.mockResolvedValue({ + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [{ ...installation, accessToken: 'private' }], + privateKey: 'private', + }) + const response = await GET(request) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { organizationId: 'org-1', signal: request.signal }, + }) + ) + expect(await response.json()).toEqual({ + success: true, + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [installation], + }) + }) + + it('forwards POST cancellation and only returns the safe credential projection', async () => { + const request = new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1', installationId: '123' }), + }) + mocks.connect.mockResolvedValue({ + credential: { + id: 'cred-1', + displayName: 'GitHub · acme', + encryptedServiceAccountKey: 'private', + }, + created: true, + }) + const response = await POST(request) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.connect).toHaveBeenCalledWith( + expect.objectContaining({ + input: { organizationId: 'org-1', installationId: '123', signal: request.signal }, + }) + ) + expect(await response.json()).toEqual({ + success: true, + credential: { id: 'cred-1', displayName: 'GitHub · acme' }, + }) + }) + + it.each([ + [ + new OrchestrationError('forbidden', 'Organization administrator access is required'), + 403, + 'Organization administrator access is required', + ], + [ + new GitHubInstallationError('Installation permission denied', 403), + 403, + 'Installation permission denied', + ], + [ + new GitHubInstallationError('GitHub is temporarily unavailable', 503), + 502, + 'GitHub is temporarily unavailable', + ], + [ + new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'private refresh details', + 401 + ), + 401, + 'Reconnect your GitHub account to continue installation setup', + ], + [new Error('private database details'), 500, 'Internal server error'], + ] as const)( + 'projects %s without successful installation data', + async (error, status, message) => { + mocks.list.mockRejectedValue(error) + const response = await GET(new NextRequest(`${URL}?organizationId=org-1`)) + expect(response.status).toBe(status) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body.error).toBe(message) + expect(body).not.toHaveProperty('installations') + expect(body).not.toHaveProperty('credential') + } + ) +}) diff --git a/apps/sim/app/api/knowledge/github/installations/route.ts b/apps/sim/app/api/knowledge/github/installations/route.ts new file mode 100644 index 00000000000..1ec3700a125 --- /dev/null +++ b/apps/sim/app/api/knowledge/github/installations/route.ts @@ -0,0 +1,53 @@ +import { + connectGitHubSearchInstallationContract, + listGitHubSearchInstallationsContract, +} from '@/lib/api/contracts/knowledge/github-installations' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { + connectGitHubSearchInstallation, + listGitHubSearchInstallations, +} from '@/lib/knowledge/application/github-installations' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' + +const errorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { + if (error instanceof GitHubInstallationError) + return internalErrorResponse(error.status === 403 ? 403 : 502, { error: error.message }) + if (error instanceof ManagedOAuthCredentialError) + return internalErrorResponse(error.statusCode, { + error: 'Reconnect your GitHub account to continue installation setup', + }) + return null +}) + +export const GET = defineInternalJsonRoute({ + contract: listGitHubSearchInstallationsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listGitHubInstallations, + rateLimit: internalRateLimits.user({ bucketName: 'github-search-installations' }), + errorPolicy, + mapInput: ({ query }, { request }) => ({ ...query, signal: request.signal }), + useCase: listGitHubSearchInstallations, + present: (result) => ({ success: true, ...result }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) + +export const POST = defineInternalJsonRoute({ + contract: connectGitHubSearchInstallationContract, + auth: internalSessionAuth, + operation: knowledgeOperations.connectGitHubInstallation, + rateLimit: internalRateLimits.user({ bucketName: 'github-search-installations' }), + errorPolicy, + mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }), + useCase: connectGitHubSearchInstallation, + present: ({ credential }) => ({ success: true, credential }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx index dd9ad344035..3873cc1d046 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx @@ -506,6 +506,7 @@ describe('organization provider management', () => { mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) await render('slack') await click('Set up Slack app') + await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled()) const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) expect(query.get('connectedAccounts')).toBe('slack') expect(query.has('addConnector')).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx index 925e601b84a..f1e727121d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ refetchCredentials: vi.fn(), oauthModal: vi.fn(), serviceAccountModal: vi.fn(), + githubInstallationModal: vi.fn(), serviceAccountTarget: null as ServiceAccountConnectTarget | null, memberAccess: true, mirroredAccess: true, @@ -173,6 +174,26 @@ vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-confi return null }, })) +vi.mock('@/app/workspace/[workspaceId]/search/components/github-installation-modal', () => ({ + GitHubInstallationModal: (props: { + organizationId: string + onConnected: (id: string) => void + }) => { + mocks.githubInstallationModal(props) + return ( + + ) + }, +})) vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields', () => ({ useConnectorConfigFields: () => ({ sourceConfig: mocks.sourceConfig, @@ -390,6 +411,36 @@ describe('Slack member setup readiness', () => { }) describe('Search methods requiring member identity', () => { + it('selects a GitHub installation for content while preserving member access', async () => { + mocks.resolveSourceConfig.mockReturnValue({ repository: 'acme/docs' }) + await render({ + initialConnectorType: 'github', + initialAccessMode: 'members', + scope: { kind: 'organization', organizationId: 'org-1' }, + }) + expect(document.body.textContent).toContain('Sync documents with') + await act(async () => combobox('Connected members').click()) + const option = Array.from(document.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.trim() === 'Connect GitHub App' + ) + if (!option) throw new Error('Missing GitHub App option') + await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) + expect(mocks.githubInstallationModal).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-1' }) + ) + await act(async () => button('Use GitHub installation').click()) + expect(combobox('GitHub App: acme')).toBeDefined() + await act(async () => button('Add source').click()) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'github-app-credential', + accessMode: 'members', + sourceConfig: { repository: 'acme/docs' }, + }), + expect.any(Object) + ) + }) + it.each(['members', 'admin'] as const)( 'honors the locked %s entry point over a draft for the other access mode', async (accessMode) => { @@ -754,7 +805,7 @@ describe('Search setup options', () => { }) describe('Account connection dropdown', () => { - it('keeps GitHub browsing credentials out of the primary form while preserving optional indexing-account connection', async () => { + it('offers GitHub indexing accounts directly without requiring a browsing credential', async () => { mocks.credentials = [] await render({ initialConnectorType: 'github', @@ -763,9 +814,7 @@ describe('Account connection dropdown', () => { setupDraftKey: 'github-members', }) expect(document.body.textContent).not.toContain('Account for browsing') - expect(document.body.textContent).not.toContain('Sync documents with') - expect(document.querySelector('[role="combobox"]')).toBeNull() - await act(async () => button('More options').click()) + expect(document.body.textContent).toContain('Sync documents with') await act(async () => combobox('Connected members').click()) const option = Array.from(document.querySelectorAll('[role="option"]')).find( (node) => node.textContent?.trim() === 'Connect GitHub account' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index f2a6d0cd486..66a272d8228 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -53,6 +53,7 @@ import { import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorScope } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope' +import { GitHubInstallationModal } from '@/app/workspace/[workspaceId]/search/components/github-installation-modal' import { SettingsEmptyState, SettingsQueryErrorState, @@ -146,6 +147,7 @@ export function AddConnectorModal({ const [error, setError] = useState(null) const [showOAuthModal, setShowOAuthModal] = useState(false) const [showServiceAccountModal, setShowServiceAccountModal] = useState(false) + const [showGitHubInstallationModal, setShowGitHubInstallationModal] = useState(false) const [apiKeyValue, setApiKeyValue] = useState('') const [useApiKey, setUseApiKey] = useState(!isSearchIndex) @@ -353,6 +355,8 @@ export function AddConnectorModal({ } : null + const canSetUpGitHubInstallation = + canAdmin && isSearchIndex && selectedType === 'github' && scope.kind === 'organization' const contentCredentialField = isMembersMode && connectorConfig?.supportsSeparateContentCredential ? ( <> @@ -384,6 +388,16 @@ export function AddConnectorModal({ }, ] : []), + ...(canSetUpGitHubInstallation + ? [ + { + value: '__github_installation__', + label: 'Connect GitHub App', + icon: Plus, + onSelect: () => setShowGitHubInstallationModal(true), + }, + ] + : []), ]} isLoading={credentialsLoading} disabled={isCreating} @@ -726,7 +740,7 @@ export function AddConnectorModal({ ) : null} - {!isSearchIndex && contentCredentialField} + {(!isSearchIndex || canSetUpGitHubInstallation) && contentCredentialField} {configFieldsProps && ( {showMetadata && ( <> - {isSearchIndex && contentCredentialField} + {isSearchIndex && !canSetUpGitHubInstallation && contentCredentialField} {configFieldsProps && hasOptionalSetupFields && ( )} + {showGitHubInstallationModal && + canSetUpGitHubInstallation && + isMembersMode && + scope.kind === 'organization' && ( + setShowGitHubInstallationModal(false)} + onConnected={(credentialId) => { + setContentCredentialId(credentialId) + setShowGitHubInstallationModal(false) + }} + /> + )} {showOAuthModal && connectorConfig && connectorConfig.auth.mode === 'oauth' && diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.test.tsx new file mode 100644 index 00000000000..af00ad3f4ec --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.test.tsx @@ -0,0 +1,199 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ListGitHubSearchInstallationsResponse } from '@/lib/api/contracts/knowledge/github-installations' + +const mocks = vi.hoisted(() => ({ + data: undefined as ListGitHubSearchInstallationsResponse | undefined, + error: null as Error | null, + fetching: false, + pending: false, + refetch: vi.fn(), + connectInstallation: vi.fn(), + ensureAccounts: vi.fn(), + connectAccount: vi.fn(), + onConnected: vi.fn(), +})) + +vi.mock('@/hooks/queries/github-search-installations', () => ({ + useGitHubSearchInstallations: () => ({ + data: mocks.data, + error: mocks.error, + isSuccess: Boolean(mocks.data) && !mocks.error, + isError: Boolean(mocks.error), + isFetching: mocks.fetching, + refetch: mocks.refetch, + }), + useConnectGitHubSearchInstallation: () => ({ + mutate: mocks.connectInstallation, + isPending: mocks.pending, + error: null, + }), +})) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useEnsureOrganizationAccounts: () => ({ + mutate: mocks.ensureAccounts, + isPending: false, + error: null, + }), + useConnectOrganizationAccount: () => ({ + mutate: mocks.connectAccount, + isPending: false, + error: null, + }), +})) + +import { GitHubInstallationModal } from '@/app/workspace/[workspaceId]/search/components/github-installation-modal' + +let root: Root +let container: HTMLDivElement + +async function render() { + await act(async () => { + root.render( + + ) + }) +} + +function button(label: string): HTMLButtonElement { + const match = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent?.trim() === label + ) + if (!match) throw new Error(`Missing button: ${label}`) + return match +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.data = { + success: true, + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [ + { + installationId: '123', + accountId: '456', + accountLogin: 'acme', + accountType: 'Organization', + }, + ], + } + mocks.error = null + mocks.fetching = false + mocks.pending = false + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('GitHub installation setup', () => { + it('uses only a server-verified installation and returns its credential', async () => { + await render() + const installLink = document.querySelector('a[href*="installations/new"]') + expect(installLink?.href).toBe('https://github.com/apps/sim-search/installations/new') + expect(installLink?.target).toBe('_blank') + expect(installLink?.rel).toContain('noopener') + await act(async () => button('Use installation').click()) + expect(mocks.connectInstallation).toHaveBeenCalledWith( + { organizationId: 'org-1', installationId: '123' }, + expect.any(Object) + ) + const callbacks = mocks.connectInstallation.mock.calls[0][1] + callbacks.onSuccess({ success: true, credential: { id: 'credential-1', displayName: 'acme' } }) + expect(mocks.onConnected).toHaveBeenCalledWith('credential-1') + }) + + it('requires a connected personal account before selecting an installation', async () => { + mocks.data!.needsUserConnection = true + await render() + expect(button('Use installation').disabled).toBe(true) + expect(button('Connect your GitHub account')).toBeTruthy() + expect(document.querySelector('a[href*="installations/new"]')).toBeNull() + expect(document.querySelector('[role="combobox"]')).toBeNull() + }) + + it('opens the existing managed-account connection after preserving provider setup', async () => { + mocks.data!.needsUserConnection = true + const tab = { opener: {}, closed: false, location: { href: '' }, close: vi.fn() } + vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window) + await render() + await act(async () => button('Connect your GitHub account').click()) + expect(tab.opener).toBeNull() + expect(mocks.ensureAccounts).toHaveBeenCalledWith( + { + organizationId: 'org-1', + option: { provider: 'github-repositories', label: 'GitHub', required: false }, + }, + expect.any(Object) + ) + const setup = mocks.ensureAccounts.mock.calls[0][1] + await act(async () => + setup.onSuccess({ + credentialGroup: { + options: [ + { id: 'other-option', provider: 'confluence', status: 'active' }, + { id: 'github-option', provider: 'github-repositories', status: 'active' }, + ], + }, + }) + ) + expect(mocks.connectAccount).toHaveBeenCalledWith( + { organizationId: 'org-1', optionId: 'github-option' }, + expect.any(Object) + ) + await act(async () => + mocks.connectAccount.mock.calls[0][1].onSuccess({ + invitationLink: 'https://sim.ai/credential-groups/invite/test', + }) + ) + expect(tab.location.href).toBe('https://sim.ai/credential-groups/invite/test') + expect(document.body.textContent).toContain('Finish connecting your account in the other tab') + }) + + it('does not create an enrollment when the browser blocks the account tab', async () => { + mocks.data!.needsUserConnection = true + vi.spyOn(window, 'open').mockReturnValue(null) + await render() + await act(async () => button('Connect your GitHub account').click()) + expect(mocks.ensureAccounts).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('Allow pop-ups') + }) + + it.each(['unavailable', 'error', 'refreshing', 'empty'] as const)( + 'refuses installation changes while %s', + async (state) => { + if (state === 'unavailable') mocks.data!.available = false + if (state === 'error') mocks.error = new Error('Installation lookup failed') + if (state === 'refreshing') mocks.fetching = true + if (state === 'empty') mocks.data!.installations = [] + await render() + expect(button('Use installation').disabled).toBe(true) + await act(async () => button('Use installation').click()) + expect(mocks.connectInstallation).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.tsx new file mode 100644 index 00000000000..b17d8e72c45 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/search/components/github-installation-modal.tsx @@ -0,0 +1,220 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipCombobox, + ChipLink, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + useConnectGitHubSearchInstallation, + useGitHubSearchInstallations, +} from '@/hooks/queries/github-search-installations' +import { + useConnectOrganizationAccount, + useEnsureOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' + +interface GitHubInstallationModalProps { + organizationId: string + onClose: () => void + onConnected: (credentialId: string) => void +} + +/** Installs a content account while preserving each reader's own GitHub authorization. */ +export function GitHubInstallationModal({ + organizationId, + onClose, + onConnected, +}: GitHubInstallationModalProps) { + const installations = useGitHubSearchInstallations(organizationId) + const connectInstallation = useConnectGitHubSearchInstallation() + const ensureAccounts = useEnsureOrganizationAccounts() + const connectAccount = useConnectOrganizationAccount() + const [installationId, setInstallationId] = useState(null) + const [waitingForAccount, setWaitingForAccount] = useState(false) + const [connectionError, setConnectionError] = useState(null) + const data = installations.isSuccess ? installations.data : undefined + const choices = data?.installations ?? [] + const selected = + choices.find((item) => item.installationId === installationId) ?? + (choices.length === 1 ? choices[0] : undefined) + const pending = + connectInstallation.isPending || ensureAccounts.isPending || connectAccount.isPending + const canConnect = + data?.available === true && + !data.needsUserConnection && + selected !== undefined && + !installations.isFetching && + !pending + + const connectGitHubAccount = () => { + const tab = window.open('about:blank', '_blank') + if (!tab) { + setConnectionError('Allow pop-ups for this site to connect your GitHub account.') + return + } + tab.opener = null + setConnectionError(null) + ensureAccounts.mutate( + { + organizationId, + option: { provider: 'github-repositories', label: 'GitHub', required: false }, + }, + { + onSuccess: ({ credentialGroup }) => { + const option = credentialGroup.options.find( + (item) => item.provider === 'github-repositories' && item.status === 'active' + ) + if (!option) { + tab.close() + setConnectionError('GitHub account setup is unavailable. Refresh and try again.') + return + } + if (tab.closed) return + connectAccount.mutate( + { organizationId, optionId: option.id }, + { + onSuccess: ({ invitationLink }) => { + if (tab.closed) return + tab.location.href = invitationLink + setWaitingForAccount(true) + }, + onError: () => tab.close(), + } + ) + }, + onError: () => tab.close(), + } + ) + } + + return ( + { + if (!open && !pending) onClose() + }} + > + Connect GitHub App + + + {installations.isError ? ( + void installations.refetch()} + variant='inline' + /> + ) : !data ? ( + Loading GitHub setup… + ) : !data.available ? ( + + GitHub App indexing is unavailable in this deployment. + + ) : data.needsUserConnection ? ( +
+

+ {waitingForAccount + ? 'Finish connecting your account in the other tab, then refresh.' + : 'Connect your GitHub account to verify the installations you can manage.'} +

+
+ + {waitingForAccount ? 'Open account connection' : 'Connect your GitHub account'} + + void installations.refetch()} + > + Refresh + +
+
+ ) : ( +
+

+ Install the app on your account or organization and choose its repositories, then + refresh. +

+
+ {data.installUrl && ( + + Install GitHub App + + )} + void installations.refetch()} + > + Refresh + +
+
+ )} +
+ {data?.available && !data.needsUserConnection && ( + + {choices.length > 0 ? ( + ({ + value: item.installationId, + label: item.accountLogin, + }))} + value={selected?.installationId} + onChange={setInstallationId} + placeholder='Select an installation' + disabled={pending || installations.isFetching} + /> + ) : ( + + No eligible installations found. + + )} + + )} + + {connectionError ?? + ensureAccounts.error?.message ?? + connectAccount.error?.message ?? + connectInstallation.error?.message} + +
+ { + if (!canConnect || !selected) return + connectInstallation.mutate( + { organizationId, installationId: selected.installationId }, + { onSuccess: ({ credential }) => onConnected(credential.id) } + ) + }, + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx index 8dc2b768cc2..838cb7aec2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx @@ -430,7 +430,7 @@ describe('organization setup entry points', () => { expect(mocks.replace).not.toHaveBeenCalled() expect(document.querySelector('button[aria-label="Choose another source"]')).toBeNull() expect(document.body.textContent).not.toContain('Sync using') - expect(document.body.textContent).not.toContain('Sync documents with') + expect(document.body.textContent).toContain('Sync documents with') expect(button('Add source')).toBeEnabled() await click(button('Add source')) expect(mocks.create).toHaveBeenCalledWith( diff --git a/apps/sim/connectors/github/github.test.ts b/apps/sim/connectors/github/github.test.ts index f51615a78ee..53eaa6594d9 100644 --- a/apps/sim/connectors/github/github.test.ts +++ b/apps/sim/connectors/github/github.test.ts @@ -49,6 +49,40 @@ describe('githubConnector member listing', () => { expect(hydrated?.contentHash).toBe(listing.documents[0]?.contentHash) }) + it.each(['master', 'develop'])( + 'uses the actual %s default for an installation content pass with a blank branch', + async (defaultBranch) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ default_branch: defaultBranch }))) + .mockResolvedValueOnce(treeResponse([treeFile('readme.md', 'sha')])) + .mockResolvedValueOnce(new Response('text')) + vi.stubGlobal('fetch', fetchMock) + const context = {} + const config = { repository: 'owner/repo', githubRepositoryId: '101', branch: ' ' } + const listing = await githubConnector.listDocuments( + 'installation-token', + config, + undefined, + context + ) + const hydrated = await githubConnector.getDocument( + 'installation-token', + config, + 'readme.md', + context + ) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://api.github.com/repos/owner/repo', + `https://api.github.com/repos/owner/repo/git/trees/${defaultBranch}?recursive=1`, + 'https://api.github.com/repos/owner/repo/git/blobs/sha', + ]) + expect(listing.documents[0]?.metadata?.branch).toBe(defaultBranch) + expect(hydrated?.metadata?.branch).toBe(defaultBranch) + expect(hydrated?.contentHash).toBe(listing.documents[0]?.contentHash) + } + ) + it('preserves the default main branch for existing general KB sources', async () => { const fetchMock = vi.fn().mockResolvedValue(treeResponse([])) vi.stubGlobal('fetch', fetchMock) @@ -72,6 +106,20 @@ describe('githubConnector member listing', () => { ) }) + it('uses an explicitly configured installation branch without a repository metadata lookup', async () => { + const fetchMock = vi.fn().mockResolvedValue(treeResponse([])) + vi.stubGlobal('fetch', fetchMock) + await githubConnector.listDocuments( + 'installation-token', + { repository: 'owner/repo', githubRepositoryId: '101', branch: 'release/docs' }, + undefined, + {} + ) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://api.github.com/repos/owner/repo/git/trees/release%2Fdocs?recursive=1' + ) + }) + it('validates a member source against its actual default branch', async () => { const fetchMock = vi .fn() diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index 374b88826e8..7a2c71d0218 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -4,6 +4,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { z } from 'zod' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { type RetryOptions, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { parseGitHubRepository } from '@/lib/oauth/github-repository' import { githubConnectorMeta } from '@/connectors/github/meta' import { fetchGitHubWithRetry as fetchWithRetry } from '@/connectors/github/request' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -54,28 +55,6 @@ function isBinaryBuffer(buf: Buffer): boolean { return false } -/** - * Parses the repository string into owner and repo. - */ -function parseRepo(repository: string): { owner: string; repo: string } { - const cleaned = repository - .trim() - .replace(/^https?:\/\/github\.com\//i, '') - .replace(/\/$/, '') - .replace(/\.git$/, '') - const parts = cleaned.split('/') - if ( - parts.length !== 2 || - !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(parts[0] ?? '') || - !/^[a-z\d_.-]+$/i.test(parts[1] ?? '') || - parts[1] === '.' || - parts[1] === '..' - ) { - throw new Error(`Invalid repository format: "${repository}". Use "owner/repo".`) - } - return { owner: parts[0], repo: parts[1] } -} - /** * File extension filter set from user config. Returns null if no filter (accept all). */ @@ -180,7 +159,7 @@ async function repositoryRequestError( return new GitHubApiError(message, response.status) } -/** Member sources follow the repository default; existing workspace sources retain main. */ +/** Search sources follow the repository default; existing workspace sources retain main. */ async function resolveBranch( accessToken: string, owner: string, @@ -191,7 +170,8 @@ async function resolveBranch( ): Promise { const configuredBranch = typeof sourceConfig.branch === 'string' ? sourceConfig.branch.trim() : '' if (configuredBranch) return configuredBranch - if (!isPerMemberListing(syncContext)) return 'main' + const isInstallationSource = typeof sourceConfig.githubRepositoryId === 'string' + if (!isPerMemberListing(syncContext) && !isInstallationSource) return 'main' if (typeof syncContext?.githubBranch === 'string') return syncContext.githubBranch const response = await fetchWithRetry( @@ -405,7 +385,7 @@ export const githubConnector: ConnectorConfig = { cursor?: string, syncContext?: Record ): Promise => { - const { owner, repo } = parseRepo(sourceConfig.repository as string) + const { owner, repo } = parseGitHubRepository(sourceConfig.repository as string) const position = readCursor(cursor, syncContext) const branch = position?.branch ?? (await resolveBranch(accessToken, owner, repo, sourceConfig, syncContext)) @@ -510,7 +490,7 @@ export const githubConnector: ConnectorConfig = { externalId: string, syncContext?: Record ): Promise => { - const { owner, repo } = parseRepo(sourceConfig.repository as string) + const { owner, repo } = parseGitHubRepository(sourceConfig.repository as string) const path = externalId try { @@ -578,7 +558,7 @@ export const githubConnector: ConnectorConfig = { let owner: string let repo: string try { - const parsed = parseRepo(repository) + const parsed = parseGitHubRepository(repository) owner = parsed.owner repo = parsed.repo } catch (error) { diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx index e3961080663..5bb56a7e043 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx @@ -14,15 +14,19 @@ const mocks = vi.hoisted(() => ({ addAsync: vi.fn(), configure: vi.fn(), setup: vi.fn(), + accounts: vi.fn(), update: vi.fn(), remove: vi.fn(), reset: vi.fn(), slack: vi.fn<(props: unknown) => null>(() => null), addError: null as Error | null, + updatePending: false, })) vi.mock('@/hooks/queries/organization-accounts', () => ({ + useOrganizationAccounts: mocks.accounts, + useEnsureOrganizationAccounts: () => ({ isPending: false, error: null }), useUpdateOrganizationAccounts: () => ({ - isPending: false, + isPending: mocks.updatePending, mutate: mocks.update, reset: mocks.reset, }), @@ -44,8 +48,15 @@ vi.mock('@/hooks/queries/organization-accounts', () => ({ vi.mock('@/ee/credential-groups/components/slack-managed-users-modal', () => ({ SlackManagedUsersModal: mocks.slack, })) +vi.mock('@/ee/credential-groups/components/organization-account-people', () => ({ + OrganizationAccountPeople: () => null, +})) +vi.mock('@/ee/credential-groups/components/organization-account-workspace-access', () => ({ + OrganizationAccountWorkspaceAccess: () => null, +})) import { OrganizationAccountProviders } from '@/ee/credential-groups/components/organization-account-providers' +import { OrganizationConnectedAccounts } from '@/ee/credential-groups/components/organization-connected-accounts' const group: NonNullable = { id: 'group-1', @@ -83,6 +94,13 @@ const gmail: NonNullable['optio status: 'active', configurationStatus: 'ready', } +const github: NonNullable['options'][number] = { + ...gmail, + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering GitHub', + required: true, +} describe('organization provider configuration UI', () => { let root: Root @@ -99,6 +117,7 @@ describe('organization provider configuration UI', () => { mocks.add.mockImplementation((_input, { onSuccess }) => onSuccess()) mocks.update.mockImplementation((_input, { onSuccess }) => onSuccess()) mocks.addError = null + mocks.updatePending = false container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -205,6 +224,98 @@ describe('organization provider configuration UI', () => { expect(container.textContent).not.toContain('Indexing') }) + it('updates current provider configurations while preserving their IDs and saved settings', async () => { + const slack = { + ...gmail, + id: 'slack-option', + provider: 'slack' as const, + label: 'Company Slack', + slackBotCredentialId: 'bot-1', + requiredScopes: ['search:read'], + } + await render([], [github, gmail, slack]) + await clickButton('Update configurations') + expect(mocks.update).toHaveBeenCalledExactlyOnceWith( + { + organizationId: 'org-1', + groupId: 'group-1', + update: { + options: [ + { + id: github.id, + provider: github.provider, + label: github.label, + required: github.required, + }, + { + id: gmail.id, + provider: gmail.provider, + label: gmail.label, + required: gmail.required, + }, + { + id: slack.id, + provider: slack.provider, + label: slack.label, + required: slack.required, + slackBotCredentialId: slack.slackBotCredentialId, + requiredScopes: slack.requiredScopes, + }, + ], + }, + }, + expect.any(Object) + ) + expect(mocks.add).not.toHaveBeenCalled() + expect(mocks.remove).not.toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('Provider configurations updated') + }) + + it('disables configuration updates while a provider mutation is pending', async () => { + mocks.updatePending = true + await render([], [github]) + const button = Array.from(document.querySelectorAll('button')).find( + (node) => node.textContent === 'Update configurations' + ) + expect(button?.disabled).toBe(true) + await act(async () => button?.click()) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('surfaces a configuration update failure without removing the provider', async () => { + mocks.update.mockImplementation((_input, { onError }) => + onError(new Error('GitHub App configuration is unavailable')) + ) + await render([], [github]) + await clickButton('Update configurations') + expect(toast.error).toHaveBeenCalledWith('GitHub App configuration is unavailable') + expect(toast.success).not.toHaveBeenCalled() + expect(container.textContent).toContain('GitHub') + expect(mocks.remove).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'shows configuration updates only to administrators: %s', + async (canManage) => { + mocks.accounts.mockReturnValue({ + data: { + canManage, + credentialGroup: { ...group, options: [github] }, + availableProviders: ['github-repositories'], + }, + }) + await act(async () => + root.render( + + + + ) + ) + expect(container.textContent?.includes('Update configurations')).toBe(canManage) + expect(mocks.update).not.toHaveBeenCalled() + } + ) + it('opens Slack app configuration directly with the existing scopes', async () => { await render( [], diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index f5cce773c30..fb6cf96561a 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -64,10 +64,25 @@ export function OrganizationAccountProviders({ (option) => { const common = { id: option.id, label: option.label, required: option.required } return option.provider === 'slack' - ? { ...common, provider: 'slack', requiredScopes: option.requiredScopes } + ? { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + requiredScopes: option.requiredScopes, + } : { ...common, provider: option.provider } } ) + const updateConfigurations = () => { + if (pending) return + update.mutate( + { organizationId, groupId: group.id, update: { options } }, + { + onSuccess: () => toast.success('Provider configurations updated'), + onError: (error) => toast.error(error.message), + } + ) + } const addProvider = (choice: OrganizationAccountProviderChoice) => { if (choice.kind === 'mcp') { if (choice.connectorId === 'databricks') { @@ -160,20 +175,33 @@ export function OrganizationAccountProviders({ } - disabled={pending} - onClick={() => { - update.reset() - addMcp.reset() - removeMcp.reset() - setCatalogOpen(true) - }} - > - Add provider - +
+ {options.length > 0 && ( + + Update configurations + + )} + } + disabled={pending} + onClick={() => { + update.reset() + addMcp.reset() + removeMcp.reset() + setCatalogOpen(true) + }} + > + Add provider + +
} > + {options.length > 0 && ( +

+ Apply the current app configuration. Accounts whose app configuration changed will need + to reconnect. +

+ )}
{rows.map(({ id, name, icon: Icon, configure, choice }) => ( ({ requestJson: vi.fn() })) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) + +import { + githubSearchInstallationKeys, + useConnectGitHubSearchInstallation, + useGitHubSearchInstallations, +} from '@/hooks/queries/github-search-installations' +import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' + +let root: Root +let queryClient: QueryClient + +beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + root = createRoot(document.createElement('div')) + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + queryClient.clear() +}) + +describe('GitHub installation queries', () => { + it('forwards cancellation and keeps installations scoped to the organization', async () => { + mocks.requestJson.mockResolvedValue({ + success: true, + available: false, + installUrl: null, + needsUserConnection: false, + installations: [], + }) + function Probe() { + useGitHubSearchInstallations('org-1') + return null + } + await act(async () => + root.render( + + + + ) + ) + expect(mocks.requestJson).toHaveBeenCalledWith(listGitHubSearchInstallationsContract, { + query: { organizationId: 'org-1' }, + signal: expect.any(AbortSignal), + }) + expect(queryClient.getQueryData(githubSearchInstallationKeys.list('org-1'))).toBeDefined() + expect(queryClient.getQueryData(githubSearchInstallationKeys.list('org-2'))).toBeUndefined() + }) + + it('does not list installations without an organization', async () => { + function Probe() { + useGitHubSearchInstallations() + return null + } + await act(async () => + root.render( + + + + ) + ) + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + + it('refreshes the selected organization credential picker after connecting', async () => { + const ownKey = oauthCredentialKeys.list('github-repositories', '', '', 'org-1') + const otherKey = oauthCredentialKeys.list('github-repositories', '', '', 'org-2') + const installationsKey = githubSearchInstallationKeys.list('org-1') + for (const key of [ownKey, otherKey, installationsKey]) queryClient.setQueryData(key, []) + mocks.requestJson.mockResolvedValue({ + success: true, + credential: { id: 'cred-1', displayName: 'acme' }, + }) + let mutation: ReturnType | undefined + function Probe() { + mutation = useConnectGitHubSearchInstallation() + return null + } + await act(async () => + root.render( + + + + ) + ) + await act(async () => { + await mutation!.mutateAsync({ organizationId: 'org-1', installationId: '123' }) + }) + expect(mocks.requestJson).toHaveBeenCalledWith(connectGitHubSearchInstallationContract, { + body: { organizationId: 'org-1', installationId: '123' }, + }) + expect(queryClient.getQueryState(ownKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(installationsKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(otherKey)?.isInvalidated).toBe(false) + }) +}) diff --git a/apps/sim/hooks/queries/github-search-installations.ts b/apps/sim/hooks/queries/github-search-installations.ts new file mode 100644 index 00000000000..f7788e2d199 --- /dev/null +++ b/apps/sim/hooks/queries/github-search-installations.ts @@ -0,0 +1,53 @@ +'use client' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ConnectGitHubSearchInstallationBody, + connectGitHubSearchInstallationContract, + listGitHubSearchInstallationsContract, +} from '@/lib/api/contracts/knowledge/github-installations' +import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' + +export const GITHUB_SEARCH_INSTALLATIONS_STALE_TIME = 30_000 + +export const githubSearchInstallationKeys = { + all: ['github-search-installations'] as const, + lists: () => [...githubSearchInstallationKeys.all, 'list'] as const, + list: (organizationId?: string) => + [...githubSearchInstallationKeys.lists(), organizationId ?? ''] as const, +} + +export function useGitHubSearchInstallations(organizationId?: string) { + return useQuery({ + queryKey: githubSearchInstallationKeys.list(organizationId), + queryFn: ({ signal }) => { + if (!organizationId) throw new Error('Organization is required') + return requestJson(listGitHubSearchInstallationsContract, { + query: { organizationId }, + signal, + }) + }, + enabled: Boolean(organizationId), + staleTime: GITHUB_SEARCH_INSTALLATIONS_STALE_TIME, + refetchOnWindowFocus: 'always', + retry: false, + }) +} + +export function useConnectGitHubSearchInstallation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: ConnectGitHubSearchInstallationBody) => + requestJson(connectGitHubSearchInstallationContract, { body }), + onSuccess: (_result, { organizationId }) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: githubSearchInstallationKeys.list(organizationId), + }), + queryClient.invalidateQueries({ + queryKey: oauthCredentialKeys.list('github-repositories', '', '', organizationId), + }), + ]), + }) +} diff --git a/apps/sim/lib/api/contracts/knowledge/github-installations.test.ts b/apps/sim/lib/api/contracts/knowledge/github-installations.test.ts new file mode 100644 index 00000000000..c9d69bf5d6e --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/github-installations.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + connectGitHubSearchInstallationBodySchema, + listGitHubSearchInstallationsResponseSchema, +} from '@/lib/api/contracts/knowledge/github-installations' + +describe('GitHub installation setup contracts', () => { + it('accepts installation identifiers without permitting credential or owner overrides', () => { + const body = { organizationId: 'org-1', installationId: '12345' } + expect(connectGitHubSearchInstallationBodySchema.parse(body)).toEqual(body) + expect( + connectGitHubSearchInstallationBodySchema.safeParse({ ...body, credentialId: 'foreign' }) + .success + ).toBe(false) + for (const installationId of ['', '0', '-1', '1.5', '123/456']) { + expect( + connectGitHubSearchInstallationBodySchema.safeParse({ ...body, installationId }).success + ).toBe(false) + } + }) + + it('accepts only a fixed GitHub App installation destination', () => { + const response = { + success: true, + available: true, + needsUserConnection: false, + installations: [], + } + expect( + listGitHubSearchInstallationsResponseSchema.safeParse({ + ...response, + installUrl: 'https://github.com/apps/sim-search/installations/new', + }).success + ).toBe(true) + for (const installUrl of [ + 'https://example.com/apps/sim-search/installations/new', + 'https://github.com.evil.example/apps/sim-search/installations/new', + 'https://github.com/apps/sim-search/installations/new?redirect_uri=https://example.com', + ]) { + expect( + listGitHubSearchInstallationsResponseSchema.safeParse({ ...response, installUrl }).success + ).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/github-installations.ts b/apps/sim/lib/api/contracts/knowledge/github-installations.ts new file mode 100644 index 00000000000..28fcbdc57ab --- /dev/null +++ b/apps/sim/lib/api/contracts/knowledge/github-installations.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const githubInstallationIdSchema = z + .string() + .max(32) + .regex(/^[1-9]\d*$/, 'GitHub installation ID must be a positive integer') + +export const githubSearchInstallationSchema = z.object({ + installationId: githubInstallationIdSchema, + accountId: z + .string() + .max(32) + .regex(/^[1-9]\d*$/, 'GitHub account ID must be a positive integer'), + accountLogin: z.string().min(1).max(100), + accountType: z.enum(['User', 'Organization']), +}) +export type GitHubSearchInstallation = z.output + +export const listGitHubSearchInstallationsQuerySchema = z.object({ + organizationId: organizationIdSchema, +}) +export type ListGitHubSearchInstallationsQuery = z.input< + typeof listGitHubSearchInstallationsQuerySchema +> + +export const listGitHubSearchInstallationsResponseSchema = z.object({ + success: z.literal(true), + available: z.boolean(), + installUrl: z + .string() + .max(2000) + .regex( + /^https:\/\/github\.com\/apps\/[a-z0-9-]+\/installations\/new$/, + 'GitHub installation URL must use the configured GitHub App' + ) + .nullable(), + needsUserConnection: z.boolean(), + installations: z.array(githubSearchInstallationSchema).max(1000), +}) +export type ListGitHubSearchInstallationsResponse = z.output< + typeof listGitHubSearchInstallationsResponseSchema +> + +export const listGitHubSearchInstallationsContract = defineRouteContract({ + method: 'GET', + path: '/api/knowledge/github/installations', + query: listGitHubSearchInstallationsQuerySchema, + response: { mode: 'json', schema: listGitHubSearchInstallationsResponseSchema }, +}) + +export const connectGitHubSearchInstallationBodySchema = z + .object({ + organizationId: organizationIdSchema, + installationId: githubInstallationIdSchema, + }) + .strict() +export type ConnectGitHubSearchInstallationBody = z.input< + typeof connectGitHubSearchInstallationBodySchema +> + +export const connectGitHubSearchInstallationResponseSchema = z.object({ + success: z.literal(true), + credential: z.object({ + id: z.string().min(1).max(200), + displayName: z.string().min(1).max(500), + }), +}) +export type ConnectGitHubSearchInstallationResponse = z.output< + typeof connectGitHubSearchInstallationResponseSchema +> + +export const connectGitHubSearchInstallationContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/github/installations', + body: connectGitHubSearchInstallationBodySchema, + response: { mode: 'json', schema: connectGitHubSearchInstallationResponseSchema }, +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 55b06fbac57..ae0f3ddbc71 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -492,6 +492,9 @@ export const env = createEnv({ GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret GITHUB_APP_CLIENT_ID: z.string().optional(), GITHUB_APP_CLIENT_SECRET: z.string().optional(), + GITHUB_APP_ID: z.string().optional(), + GITHUB_APP_PRIVATE_KEY: z.string().optional(), + GITHUB_APP_SLUG: z.string().optional(), DISABLE_GOOGLE_AUTH: z.boolean().optional(), // Disable Google OAuth login even when credentials are configured DISABLE_GITHUB_AUTH: z.boolean().optional(), // Disable GitHub OAuth login even when credentials are configured DISABLE_MICROSOFT_AUTH: z.boolean().optional(), // Disable Microsoft OAuth login even when credentials are configured diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 7da948017bc..6fad8d50de4 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -15,6 +15,7 @@ import { allowedOrganizationIntegrationTypes, principalUserId, } from '@/lib/integrations/principal-scope.server' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -115,6 +116,16 @@ function providerField( } function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GITHUB_INSTALLATION_PROVIDER_ID) { + return { + name: 'GitHub App installation', + description: 'Index repository content with a GitHub App installation.', + docsUrl: 'https://docs.sim.ai/search/github', + helpText: + 'Connect an installation through your organization’s Search integrations. Each person connects their own GitHub account to establish access.', + fields: [], + } + } if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { return { name: 'Google service account', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 3996c82c868..4fc12acab7b 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -34,6 +34,7 @@ import { getTokenServiceAccountValidator, type TokenServiceAccountSecretBlob, } from '@/lib/credentials/token-service-accounts/server' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, @@ -371,6 +372,11 @@ export async function verifyAndBuildServiceAccountSecret( providerId: string, fields: ServiceAccountSecretFields ): Promise { + if (providerId === GITHUB_INSTALLATION_PROVIDER_ID) { + throw new ServiceAccountSecretError( + 'Connect a GitHub App installation through your organization’s Search integrations' + ) + } const builder = Object.hasOwn(SERVICE_ACCOUNT_SECRET_BUILDERS, providerId) ? SERVICE_ACCOUNT_SECRET_BUILDERS[providerId] : undefined diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index 470b293640a..456332bb029 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -49,6 +49,7 @@ const EXPECTED_COVERAGE: Record = { 'calcom-service-account': ['cal-com'], 'claude-platform-service-account': [], 'clickup-service-account': ['clickup'], + 'github-app-installation': ['github'], 'google-service-account': [ 'gmail', 'google-bigquery', diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index dabef6dea0a..41d024a4445 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -9,6 +9,8 @@ import { getIntegrationAvailability, isOAuthServiceDeploymentAvailable, } from '@/lib/integrations/availability.server' +import { getGitHubInstallationConfiguration } from '@/lib/oauth/github-installation' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import type { OAuthServiceMetadata } from '@/lib/oauth/types' import { getAllOAuthServices } from '@/lib/oauth/utils' import { getBlock } from '@/blocks/registry' @@ -115,6 +117,14 @@ export function createIntegrationCredentialVisibility({ providerId: string, owners: readonly OAuthServiceMetadata[] ): boolean => { + if (providerId === GITHUB_INSTALLATION_PROVIDER_ID) { + return ( + getGitHubInstallationConfiguration().configured && + owners.some( + (service) => isServiceAllowed(service) && visibleAvailability(service).length > 0 + ) + ) + } const gatingBlockType = getServiceAccountGatingBlockType(providerId) if (gatingBlockType) { const gatingBlock = getBlock(gatingBlockType) diff --git a/apps/sim/lib/knowledge/__integration__/github-member.integration.ts b/apps/sim/lib/knowledge/__integration__/github-member.integration.ts index f7f5f8da4cb..31cd2ddc32e 100644 --- a/apps/sim/lib/knowledge/__integration__/github-member.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/github-member.integration.ts @@ -3,7 +3,7 @@ * connector registry, member sync, storage, chunking, and application authorization. * Provider replies and embeddings are deterministic; no live GitHub account is used. */ -import { createHash } from 'node:crypto' +import { createHash, generateKeyPairSync, verify } from 'node:crypto' import { posix } from 'node:path' import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' @@ -11,12 +11,15 @@ import { credential, credentialGroup, credentialGroupEnrollment, + credentialMember, document, embedding, knowledgeBase, knowledgeConnector, knowledgeConnectorMember, knowledgeDocumentObservation, + member, + organization, rateLimitBucket, resourcePolicy, user, @@ -40,9 +43,13 @@ vi.mock('@/lib/embeddings', async () => ({ }), })) -import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + resolveBillingAttribution, + resolveOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' import { env } from '@/lib/core/config/env' import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { encryptSecret } from '@/lib/core/security/encryption' import { resetStorageMethod } from '@/lib/core/storage' import { compileCredentialGroupWorkflowAccessPolicy } from '@/lib/credential-groups/application/workflow-access-policy' import { @@ -65,11 +72,15 @@ import { seedKnowledgeAclFixture, seedKnowledgeMemberFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { GITHUB_READ_SOURCE_TIMEOUT_MS } from '@/lib/knowledge/access/github-installation' +import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' import { subjectToken } from '@/lib/knowledge/access/tokens' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' +import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document' import { searchKnowledge } from '@/lib/knowledge/application/search' +import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview' import { listSearchSources } from '@/lib/knowledge/application/search-sources' import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access' import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine' @@ -77,8 +88,11 @@ import { MEMBER_SUSPENDED_PURGE_DAYS, MEMBER_TOMBSTONE_PURGE_DAYS, } from '@/lib/knowledge/connectors/sync-limits' +import { getDocuments } from '@/lib/knowledge/documents/service' +import { getTagUsageStats } from '@/lib/knowledge/tags/service' import { deleteFile } from '@/lib/uploads/core/storage-service' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const redisUrl = process.env.KNOWLEDGE_ACL_TEST_REDIS_URL if (redisUrl) { @@ -95,7 +109,10 @@ if (redisUrl) { /** Private repositories require the intersection of installation access and member access. */ interface RepositoryFixture { + id: number + public: boolean installed: boolean + stallRef: boolean readers: Set defaultBranch: string files: Map @@ -117,7 +134,23 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { id: env.GITHUB_APP_CLIENT_ID, secret: env.GITHUB_APP_CLIENT_SECRET, redis: env.REDIS_URL, + appId: env.GITHUB_APP_ID, + privateKey: env.GITHUB_APP_PRIVATE_KEY, + slug: env.GITHUB_APP_SLUG, } + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + let organizationSource = false + let installationSuspended = false + let referenceObserved: ((repository: string) => void) | undefined + const installation = () => ({ + id: 42, + app_id: 1, + client_id: 'github-fixture-client', + account: { id: 90, login: 'fixture', type: 'Organization' }, + repository_selection: 'selected', + permissions: { contents: 'read', metadata: 'read' }, + suspended_at: installationSuspended ? new Date().toISOString() : null, + }) let oauthStateKey: string | undefined let oauthVerification: { codeVerifier: string; redirectUri: string } | undefined const tokenFor = (userId: string) => `ghu_fixture_${userId}` @@ -137,7 +170,10 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { function repository(name: string, readers = [ids.aliceId, ids.bobId]) { const value: RepositoryFixture = { + id: 9001 + repositories.size, + public: false, installed: true, + stallRef: false, readers: new Set(readers), defaultBranch: 'trunk', files: new Map([ @@ -194,18 +230,65 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { refresh_token_expires_in: 15897600, }) } - if (url.origin !== 'https://api.github.com' || request.method !== 'GET') + if (url.origin !== 'https://api.github.com') throw new Error(`Unexpected outbound request: ${request.method} ${url.origin}${url.pathname}`) + const bearer = request.headers.get('authorization')?.slice(7) ?? '' + if (url.pathname.startsWith('/app/installations/') || url.pathname.endsWith('/installation')) { + const [header, payload, signature] = bearer.split('.') + expect( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url') + ) + ).toBe(true) + expect(JSON.parse(Buffer.from(payload, 'base64url').toString()).iss).toBe( + 'github-fixture-client' + ) + requests.push({ userId: 'app', path: url.pathname }) + if (url.pathname === '/app/installations/42/access_tokens') { + expect(request.method).toBe('POST') + const body = await request.json() + expect(body).toMatchObject({ + permissions: { contents: 'read', metadata: 'read' }, + }) + expect(body.repository_ids).toHaveLength(1) + const repositoryId = body.repository_ids[0] + expect( + [...repositories.values()].some( + (repository) => repository.id === repositoryId && repository.installed + ) + ).toBe(true) + return Response.json({ + token: `ghs_fixture_installation_${repositoryId}`, + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + permissions: body.permissions, + repositories: [{ id: repositoryId }], + }) + } + expect(request.method).toBe('GET') + const repositoryInstallation = url.pathname.match(/^\/repos\/fixture\/([^/]+)\/installation$/) + if (repositoryInstallation && !repositories.get(repositoryInstallation[1])?.installed) + return Response.json({ message: 'Not Found' }, { status: 404 }) + expect(url.pathname === '/app/installations/42' || Boolean(repositoryInstallation)).toBe(true) + return Response.json(installation()) + } + if (request.method !== 'GET') throw new Error(`Unexpected GitHub method: ${request.method}`) + const installationRepository = bearer.match(/^ghs_fixture_installation_(\d+)$/)?.[1] + const installationToken = Boolean(installationRepository) const member = enrolled.members.find((candidate) => [tokenFor(candidate.userId), `${tokenFor(candidate.userId)}_refreshed`].some( (token) => request.headers.get('authorization') === `Bearer ${token}` ) ) - if (!member) throw new Error('GitHub request did not use an enrolled member token') + if (!member && !installationToken) + throw new Error('GitHub request did not use an enrolled member or installation token') + const actingId = installationToken ? 'installation' : member!.userId expect(request.headers.get('x-github-api-version')).toBe('2022-11-28') - requests.push({ userId: member.userId, path: `${url.pathname}${url.search}` }) + requests.push({ userId: actingId, path: `${url.pathname}${url.search}` }) if (url.pathname === '/user') { - expect(member.userId).toBe(ids.aliceId) + expect(actingId).toBe(ids.aliceId) return Response.json({ id: 101, login: 'github-fixture-alice', @@ -218,24 +301,48 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { expect(url.searchParams.get('page')).toBe('1') return Response.json([ { email: 'personal@github-fixture.test', primary: true, verified: true }, - { email: `${member.userId}@fixture.test`, primary: false, verified: true }, + { email: `${actingId}@fixture.test`, primary: false, verified: true }, ]) } const match = url.pathname.match(/^\/repos\/fixture\/([^/]+)(.*)$/) if (!match) throw new Error(`Unexpected GitHub endpoint: ${url.pathname}`) const source = repositories.get(match[1]) if (!source) throw new Error('Unexpected GitHub repository') - if (source.throttledReaders.has(member.userId)) + if (installationToken) expect(installationRepository).toBe(String(source.id)) + if (source.throttledReaders.has(actingId)) return Response.json( { message: 'You have exceeded a secondary rate limit.' }, { status: 403 } ) - if (!source.installed || !source.readers.has(member.userId)) + if ( + (installationToken && !source.installed) || + (!installationToken && !source.public && (!source.installed || !source.readers.has(actingId))) + ) return Response.json( { message: 'Resource not accessible by integration' }, { status: source.deniedStatus } ) - if (!match[2]) return Response.json({ private: true, default_branch: source.defaultBranch }) + if (!match[2]) + return Response.json({ + id: source.id, + owner: { id: 90 }, + full_name: `fixture/${match[1]}`, + private: !source.public, + default_branch: source.defaultBranch, + }) + if (match[2].startsWith('/git/ref/heads/')) { + referenceObserved?.(match[1]) + if (source.stallRef) + return new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => reject(request.signal.reason), { + once: true, + }) + }) + const ref = decodeURIComponent(match[2].slice('/git/ref/heads/'.length)) + return ref === source.defaultBranch + ? Response.json({ ref: `refs/heads/${ref}`, object: { type: 'commit', sha: shaFor(ref) } }) + : Response.json({ message: 'Not Found' }, { status: 404 }) + } if (match[2].startsWith('/git/trees/')) { const ref = decodeURIComponent(match[2].slice('/git/trees/'.length)) const treeSha = shaFor(JSON.stringify([[...source.files], [...source.symlinks]])) @@ -264,7 +371,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { }) } if (match[2].startsWith('/git/blobs/')) { - if (source.throttledBlobReaders.has(member.userId)) + if (source.throttledBlobReaders.has(actingId)) return Response.json( { message: 'You have exceeded a secondary rate limit.' }, { status: 403 } @@ -311,6 +418,9 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { repositories.clear() requests.length = 0 refreshedUsers.clear() + organizationSource = false + installationSuspended = false + referenceObserved = undefined oauthStateKey = undefined oauthVerification = undefined Object.assign(env, { @@ -435,6 +545,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { ) ) await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) vi.unstubAllGlobals() } @@ -443,6 +554,9 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { Object.assign(env, { GITHUB_APP_CLIENT_ID: previousClient.id, GITHUB_APP_CLIENT_SECRET: previousClient.secret, + GITHUB_APP_ID: previousClient.appId, + GITHUB_APP_PRIVATE_KEY: previousClient.privateKey, + GITHUB_APP_SLUG: previousClient.slug, }) await db.$client.end() }) @@ -493,14 +607,16 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { .orderBy(document.externalId) } - async function search(principal: Principal) { + async function search(principal: Principal, searchMode: 'hybrid' | 'vector' = 'hybrid') { const result = await searchKnowledge.execute({ principal, input: { - workspaceId: ids.workspaceId, + ...(organizationSource + ? { organizationId: ids.organizationId } + : { workspaceId: ids.workspaceId }), knowledgeBaseIds: [ids.knowledgeBaseId], query: 'Orion', - searchMode: 'hybrid', + searchMode, topK: 20, }, }) @@ -528,6 +644,260 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => { } } + it('indexes an organization installation once and denies live user, app, and org revocations before search or reads', async () => { + organizationSource = true + Object.assign(env, { + GITHUB_APP_ID: '1', + GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + GITHUB_APP_SLUG: 'github-fixture', + }) + await db.insert(member).values([ + { id: generateId(), organizationId: ids.organizationId, userId: ids.aliceId, role: 'owner' }, + { id: generateId(), organizationId: ids.organizationId, userId: ids.bobId, role: 'member' }, + ]) + await db + .update(knowledgeBase) + .set({ workspaceId: null, organizationId: ids.organizationId }) + .where(eq(knowledgeBase.id, ids.knowledgeBaseId)) + await db + .update(credentialGroup) + .set({ workspaceId: null, organizationId: ids.organizationId }) + .where(eq(credentialGroup.id, enrolled.groupId)) + await db + .update(credential) + .set({ workspaceId: null, organizationId: ids.organizationId }) + .where( + inArray( + credential.id, + enrolled.members.map((entry) => entry.credentialId) + ) + ) + await db + .update(knowledgeConnectorMember) + .set({ workspaceId: null, organizationId: ids.organizationId }) + .where(eq(knowledgeConnectorMember.connectorId, enrolled.connectorId)) + const installationCredentialId = generateId() + const { encrypted } = await encryptSecret( + JSON.stringify({ + type: 'github_app_installation', + version: 1, + appId: '1', + appClientId: 'github-fixture-client', + installationId: '42', + accountId: '90', + accountType: 'Organization', + accountLogin: 'fixture', + repositorySelection: 'selected', + }) + ) + await db.insert(credential).values({ + id: installationCredentialId, + organizationId: ids.organizationId, + type: 'service_account', + providerId: 'github-app-installation', + providerSubjectId: '42', + providerTenantId: '90', + encryptedServiceAccountKey: encrypted, + displayName: 'GitHub fixture installation', + createdBy: ids.aliceId, + }) + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: installationCredentialId, + userId: ids.aliceId, + role: 'admin', + status: 'active', + }) + await db + .update(knowledgeConnector) + .set({ + credentialId: installationCredentialId, + sourceConfig: { repository: 'fixture/shared', githubRepositoryId: '9001', maxFiles: 0 }, + }) + .where(eq(knowledgeConnector.id, enrolled.connectorId)) + billing = await resolveOrganizationBillingAttribution({ + actorUserId: ids.aliceId, + organizationId: ids.organizationId, + }) + const unrelatedSources = Array.from({ length: 105 }, () => generateId()) + await db.insert(knowledgeConnector).values( + unrelatedSources.map((id) => ({ + id, + knowledgeBaseId: ids.knowledgeBaseId, + connectorType: 'github', + accessMode: 'members', + credentialId: installationCredentialId, + credentialGroupId: enrolled.groupId, + credentialGroupOptionId: enrolled.optionId, + sourceConfig: { repository: 'fixture/shared', githubRepositoryId: '9001' }, + })) + ) + await db.insert(knowledgeConnectorMember).values( + unrelatedSources.map((connectorId) => ({ + id: generateId(), + organizationId: ids.organizationId, + connectorId, + credentialId: enrolled.members[0].credentialId, + subjectToken: enrolled.members[0].subjectToken, + })) + ) + const result = await sync() + expect(result.error).toBeUndefined() + expect(result.docsHydratedOnce).toBe(1) + const [indexed] = await rows() + expect(indexed).toBeDefined() + const provider = (userId: string) => + createKnowledgeAccessProvider(actor(userId), { + organizationId: ids.organizationId, + knowledgeBaseIds: [ids.knowledgeBaseId], + }) + const page = (userId: string, offset = 0) => + getDocuments( + ids.knowledgeBaseId, + { limit: 1, offset, sortBy: 'filename', sortOrder: 'asc' }, + 'github-candidate-regression', + provider(userId) + ) + expect(await page(ids.aliceId)).toMatchObject({ + documents: [{ id: indexed.id }], + pagination: { total: 1 }, + }) + expect( + ( + await readSearchSourceOverview.execute({ + principal: actor(ids.aliceId), + input: { organizationId: ids.organizationId }, + }) + ).hasSearchableDocuments + ).toBe(true) + await db.update(document).set({ tag1: 'fixture' }).where(eq(document.id, indexed.id)) + await db.update(embedding).set({ tag1: 'fixture' }).where(eq(embedding.documentId, indexed.id)) + expect( + await getTagUsageStats(ids.knowledgeBaseId, provider(ids.aliceId), 'github-tag-regression') + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ tagSlot: 'tag1', documentCount: 1, chunkCount: 1 }), + ]) + ) + expect( + ( + await readIndexedKnowledgeDocument.execute({ + principal: actor(ids.aliceId), + input: { + organizationId: ids.organizationId, + target: { kind: 'url', url: indexed.sourceUrl! }, + limit: 1, + resultSecretRegistry: new ResolvedSecretTraceRegistry(), + }, + }) + ).documentId + ).toBe(indexed.id) + expect( + requests.filter((entry) => entry.path.includes('/git/blobs/')).map((entry) => entry.userId) + ).toEqual(['installation']) + expect(await search(actor(ids.aliceId))).toEqual([indexed.id]) + expect(await search(actor(ids.bobId))).toEqual([indexed.id]) + await assertAccess(actor(ids.bobId), indexed, true) + const source = repositories.get('shared')! + source.readers.delete(ids.bobId) + expect(await page(ids.bobId)).toMatchObject({ documents: [], pagination: { total: 0 } }) + expect(await search(actor(ids.bobId))).toEqual([]) + await assertAccess(actor(ids.bobId), indexed, false) + expect(await search(actor(ids.aliceId))).toEqual([indexed.id]) + expect( + await db + .select() + .from(knowledgeDocumentObservation) + .where(eq(knowledgeDocumentObservation.documentId, indexed.id)) + ).toHaveLength(2) + source.readers.add(ids.bobId) + source.public = true + source.installed = false + expect(await search(actor(ids.aliceId))).toEqual([]) + await assertAccess(actor(ids.aliceId), indexed, false) + source.installed = true + installationSuspended = true + expect(await search(actor(ids.aliceId))).toEqual([]) + installationSuspended = false + expect(await search(actor(ids.aliceId))).toEqual([indexed.id]) + const slowRepository = repository('slow') + const slowSourceId = generateId() + await db.insert(knowledgeConnector).values({ + id: slowSourceId, + knowledgeBaseId: ids.knowledgeBaseId, + connectorType: 'github', + accessMode: 'members', + credentialId: installationCredentialId, + credentialGroupId: enrolled.groupId, + credentialGroupOptionId: enrolled.optionId, + sourceConfig: { repository: 'fixture/slow', githubRepositoryId: String(slowRepository.id) }, + }) + expect((await sync(slowSourceId)).error).toBeUndefined() + const [slowDocument] = await rows(slowSourceId) + expect(slowDocument).toBeDefined() + const deniedRepository = repository('denied-paging', [ids.aliceId]) + const deniedSourceId = generateId() + await db.insert(knowledgeConnector).values({ + id: deniedSourceId, + knowledgeBaseId: ids.knowledgeBaseId, + connectorType: 'github', + accessMode: 'members', + credentialId: installationCredentialId, + credentialGroupId: enrolled.groupId, + credentialGroupOptionId: enrolled.optionId, + sourceConfig: { + repository: 'fixture/denied-paging', + githubRepositoryId: String(deniedRepository.id), + }, + }) + expect((await sync(deniedSourceId)).error).toBeUndefined() + const [deniedDocument] = await rows(deniedSourceId) + deniedRepository.readers.delete(ids.aliceId) + for (const [id, filename] of [ + [indexed.id, 'alpha'], + [deniedDocument.id, 'beta'], + [slowDocument.id, 'gamma'], + ]) + await db.update(document).set({ filename }).where(eq(document.id, id)) + expect(await page(ids.aliceId, 1)).toMatchObject({ + documents: [{ id: slowDocument.id }], + pagination: { total: 2, offset: 1 }, + }) + slowRepository.stallRef = true + const sourceTimers: AbortController[] = [] + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal) + const timerSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((duration) => { + if (duration !== GITHUB_READ_SOURCE_TIMEOUT_MS) return nativeTimeout(duration) + const controller = new AbortController() + sourceTimers.push(controller) + return controller.signal + }) + try { + const observed = new Set() + const candidatesStarted = new Promise((resolve) => { + referenceObserved = (name) => { + observed.add(name) + if (observed.has('shared') && observed.has('slow')) resolve() + } + }) + const pending = search(actor(ids.aliceId), 'vector') + await candidatesStarted + /** Complete the fast response's microtasks before expiring the stalled candidate. */ + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + for (const timer of sourceTimers) timer.abort(new Error('fixture source timeout')) + expect(await pending).toEqual([indexed.id]) + } finally { + timerSpy.mockRestore() + referenceObserved = undefined + slowRepository.stallRef = false + } + await db + .delete(member) + .where(and(eq(member.organizationId, ids.organizationId), eq(member.userId, ids.bobId))) + await expect(search(actor(ids.bobId))).rejects.toThrow() + await assertAccess(actor(ids.bobId), indexed, false) + }) + it.runIf(Boolean(redisUrl))( 'completes a PKCE OAuth attempt through Redis and persists a searchable scopeless credential', async () => { diff --git a/apps/sim/lib/knowledge/access/github-installation.test.ts b/apps/sim/lib/knowledge/access/github-installation.test.ts new file mode 100644 index 00000000000..1e14159bfc8 --- /dev/null +++ b/apps/sim/lib/knowledge/access/github-installation.test.ts @@ -0,0 +1,305 @@ +/** @vitest-environment node */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + GITHUB_READ_CONCURRENCY, + GITHUB_READ_RESPONSE_MAX_BYTES, + GITHUB_READ_SOURCE_TIMEOUT_MS, + GITHUB_READ_TIMEOUT_MS, + resolveGitHubInstallationReadGrants, +} from '@/lib/knowledge/access/github-installation' +import { MAX_KNOWLEDGE_ACCESS_CANDIDATES } from '@/lib/knowledge/access/types' + +const mocks = vi.hoisted(() => ({ + token: vi.fn(), + installation: vi.fn(), + repositoryInstallation: vi.fn(), + decrypt: vi.fn(), + fetch: vi.fn(), +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ resolveManagedOAuthToken: mocks.token })) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decrypt })) +vi.mock('@/lib/oauth/github-installation', () => ({ + parseGitHubInstallationBinding: (value: unknown) => value, + assertGitHubInstallationActive: mocks.installation, + assertGitHubInstallationRepositoryActive: mocks.repositoryInstallation, +})) + +const input = { + scope: { kind: 'organization' as const, organizationId: 'org-1' }, + readers: [{ credentialId: 'alice-credential', subjectToken: 's:github-repositories:-:alice' }], + knowledgeBaseIds: ['index-1'], + connectorIds: ['source-1'], +} +const source = { + connectorId: 'source-1', + contentCredentialId: 'installation-credential', + memberCredentialId: 'alice-credential', + subjectToken: 's:github-repositories:-:alice', + repository: 'company/private', + repositoryId: '123', + branch: null as string | null, +} +const binding = { installationId: '42', accountId: '90' } +const contentCredential = { + id: 'installation-credential', + key: 'encrypted-installation', + installationId: '42', + accountId: '90', +} +const grant = { + connectorId: source.connectorId, + contentCredentialId: source.contentCredentialId, + readerCredentialId: source.memberCredentialId, + readerSubjectToken: source.subjectToken, + repositoryId: source.repositoryId, +} +const metadata = { id: 123, owner: { id: 90 }, default_branch: 'main' } +const reference = { ref: 'refs/heads/main', object: { type: 'commit', sha: 'a'.repeat(40) } } + +function queueSources(rows: (typeof source)[] = [source]) { + input.connectorIds = rows.map((row) => row.connectorId) + queueTableRows(schemaMock.knowledgeConnector, rows) + queueTableRows(schemaMock.credential, [contentCredential]) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.stubGlobal('fetch', mocks.fetch) + mocks.token.mockResolvedValue({ accessToken: 'ghu_alice' }) + mocks.installation.mockResolvedValue(binding) + mocks.repositoryInstallation.mockResolvedValue(undefined) + mocks.decrypt.mockResolvedValue({ decrypted: JSON.stringify(binding) }) + mocks.fetch.mockImplementation(async (url: string) => + Response.json(url.includes('/git/ref/') ? reference : metadata) + ) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('live GitHub installation reader access', () => { + it('requires both current installation and personal Contents access for the immutable repository', async () => { + queueSources() + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([grant]) + expect(mocks.token).toHaveBeenCalledWith({ + credentialId: 'alice-credential', + organizationId: 'org-1', + expectedProviderId: 'github-repositories', + requiredScopes: [], + }) + expect(mocks.fetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://api.github.com/repos/company/private', + 'https://api.github.com/repos/company/private/git/ref/heads/main', + ]) + for (const [, init] of mocks.fetch.mock.calls) + expect(init).toMatchObject({ + headers: { Authorization: 'Bearer ghu_alice' }, + cache: 'no-store', + redirect: 'error', + }) + }) + + it('does not reuse a positive check after upstream access is revoked without a sync', async () => { + queueSources() + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([grant]) + queueSources() + mocks.fetch.mockResolvedValueOnce(new Response(null, { status: 404 })) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + expect(mocks.installation).toHaveBeenCalledTimes(2) + expect(mocks.token).toHaveBeenCalledTimes(2) + }) + + it('denies a public repository removed from the app installation even if the user could read it', async () => { + queueSources() + mocks.repositoryInstallation.mockRejectedValue(new Error('Repository installation not found')) + mocks.fetch.mockResolvedValue(Response.json({ ...metadata, private: false })) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + expect(mocks.repositoryInstallation).toHaveBeenCalledWith(binding, source.repository, { + signal: expect.any(AbortSignal), + }) + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it.each([401, 403, 404, 429, 500, 503])( + 'denies a %i response rather than trusting stored observations', + async (status) => { + queueSources() + mocks.fetch.mockResolvedValueOnce(new Response(null, { status })) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + } + ) + + it('denies metadata-only access even when repository lookup succeeds', async () => { + queueSources() + mocks.fetch + .mockResolvedValueOnce(Response.json(metadata)) + .mockResolvedValueOnce(new Response(null, { status: 403 })) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + }) + + it.each([ + { ...metadata, id: 456 }, + { ...metadata, owner: { id: 91 } }, + { ...metadata, default_branch: null }, + ])( + 'denies changed repository or account identity and incomplete provider data', + async (response) => { + queueSources() + mocks.fetch.mockResolvedValueOnce(Response.json(response)) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + expect(mocks.fetch).toHaveBeenCalledTimes(1) + } + ) + + it('never uses the installer token or another enrolled person as a fallback', async () => { + queueSources([{ ...source, subjectToken: 's:github-repositories:-:bob' }]) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + expect(mocks.token).not.toHaveBeenCalled() + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it('denies a reader who never connected without resolving an installation', async () => { + await expect(resolveGitHubInstallationReadGrants({ ...input, readers: [] })).resolves.toEqual( + [] + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mocks.installation).not.toHaveBeenCalled() + }) + + it('denies suspended installations and mismatched stored bindings', async () => { + queueSources() + mocks.installation.mockRejectedValueOnce(new Error('suspended')) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + queueSources() + mocks.decrypt.mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...binding, accountId: '91' }), + }) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + expect(mocks.fetch).not.toHaveBeenCalled() + }) + + it('keeps an allowed repository when a different repository is denied', async () => { + queueSources([ + source, + { ...source, connectorId: 'source-2', repository: 'company/denied', repositoryId: '456' }, + ]) + mocks.fetch.mockImplementation(async (url: string) => + url.includes('/denied') + ? new Response(null, { status: 404 }) + : Response.json(url.includes('/git/ref/') ? reference : metadata) + ) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([grant]) + expect(mocks.installation).toHaveBeenCalledTimes(1) + expect(mocks.token).toHaveBeenCalledTimes(1) + }) + + it('deduplicates identical repository checks only inside the current admission', async () => { + queueSources([source, { ...source, connectorId: 'source-2' }]) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toHaveLength(2) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + }) + + it('authorizes a candidate batch beyond the old 100-source cliff', async () => { + queueSources( + Array.from({ length: 101 }, (_, index) => ({ + ...source, + connectorId: `source-${index}`, + })) + ) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toHaveLength(101) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + }) + + it('bounds one candidate batch without enumerating all organization sources', async () => { + await expect( + resolveGitHubInstallationReadGrants({ + ...input, + connectorIds: Array.from( + { length: MAX_KNOWLEDGE_ACCESS_CANDIDATES + 1 }, + (_, index) => `source-${index}` + ), + }) + ).rejects.toThrow('bounded pages') + expect(mocks.installation).not.toHaveBeenCalled() + }) + + it.each(['source', 'admission'])( + 'retains completed proofs and advances other workers while a %s deadline expires', + async (deadline) => { + const overall = new AbortController() + const sourceTimers: AbortController[] = [] + vi.spyOn(AbortSignal, 'timeout').mockImplementation((duration) => { + if (duration === GITHUB_READ_TIMEOUT_MS) return overall.signal + expect(duration).toBe(GITHUB_READ_SOURCE_TIMEOUT_MS) + const timer = new AbortController() + sourceTimers.push(timer) + return timer.signal + }) + queueSources( + Array.from({ length: 6 }, (_, index) => ({ + ...source, + connectorId: `source-${index}`, + repository: `company/repo-${index}`, + })) + ) + let lastFastCheck: (() => void) | undefined + const allFastChecks = new Promise((resolve) => { + lastFastCheck = resolve + }) + mocks.fetch.mockImplementation(async (url: string) => { + if (url.includes('/repo-0')) return new Promise(() => {}) + if (url.includes('/repo-5/git/ref/')) lastFastCheck?.() + return Response.json(url.includes('/git/ref/') ? reference : metadata) + }) + const pending = resolveGitHubInstallationReadGrants(input) + await allFastChecks + /** Let the response proof finish before expiring the unrelated stalled request. */ + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + ;(deadline === 'source' ? sourceTimers[0] : overall).abort(new Error('deadline')) + const grants = await pending + expect(grants).toHaveLength(5) + expect(grants.map((entry) => entry.connectorId)).not.toContain('source-0') + expect(grants.map((entry) => entry.connectorId)).toContain('source-5') + } + ) + + it('bounds concurrent source checks and never buffers unbounded response bytes', async () => { + queueSources( + Array.from({ length: GITHUB_READ_CONCURRENCY + 2 }, (_, index) => ({ + ...source, + connectorId: `source-${index}`, + repository: `company/repo-${index}`, + })) + ) + let active = 0 + let peak = 0 + mocks.fetch.mockImplementation(async (url: string) => { + active += 1 + peak = Math.max(peak, active) + await Promise.resolve() + active -= 1 + return Response.json(url.includes('/git/ref/') ? reference : metadata) + }) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toHaveLength( + GITHUB_READ_CONCURRENCY + 2 + ) + expect(peak).toBeLessThanOrEqual(GITHUB_READ_CONCURRENCY) + queueSources() + mocks.fetch.mockResolvedValueOnce(new Response('x'.repeat(GITHUB_READ_RESPONSE_MAX_BYTES + 1))) + await expect(resolveGitHubInstallationReadGrants(input)).resolves.toEqual([]) + }) + + it('stops on cancellation while a credential refresh remains pending', async () => { + queueSources() + const controller = new AbortController() + mocks.token.mockImplementation(() => { + controller.abort(new Error('cancelled')) + return new Promise(() => {}) + }) + await expect( + resolveGitHubInstallationReadGrants({ ...input, signal: controller.signal }) + ).rejects.toThrow('cancelled') + expect(mocks.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/access/github-installation.ts b/apps/sim/lib/knowledge/access/github-installation.ts new file mode 100644 index 00000000000..0e70615154c --- /dev/null +++ b/apps/sim/lib/knowledge/access/github-installation.ts @@ -0,0 +1,317 @@ +import { db } from '@sim/db' +import { + credential, + credentialGroupEnrollment, + knowledgeBase, + knowledgeConnector, + knowledgeConnectorMember, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isPlainRecord } from '@sim/utils/object' +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { decryptSecret } from '@/lib/core/security/encryption' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' +import { + type GitHubInstallationReadGrant, + MAX_KNOWLEDGE_ACCESS_CANDIDATES, +} from '@/lib/knowledge/access/types' +import { + assertGitHubInstallationActive, + assertGitHubInstallationRepositoryActive, + parseGitHubInstallationBinding, +} from '@/lib/oauth/github-installation' +import { + GITHUB_INSTALLATION_PROVIDER_ID, + type GitHubInstallationBinding, +} from '@/lib/oauth/github-installation-types' + +const logger = createLogger('GitHubInstallationReadAccess') +export const GITHUB_READ_CONCURRENCY = 4 +export const GITHUB_READ_TIMEOUT_MS = 8000 +export const GITHUB_READ_SOURCE_TIMEOUT_MS = 4000 +export const GITHUB_READ_RESPONSE_MAX_BYTES = 64 * 1024 +const INSTALLATION_BINDING_MAX_BYTES = 16 * 1024 + +export interface GitHubReaderCredential { + credentialId: string + subjectToken: string +} + +interface GitHubReadSource { + connectorId: string + contentCredentialId: string | null + memberCredentialId: string + subjectToken: string + repository: string | null + repositoryId: string | null + branch: string | null +} + +/** Token refresh may outlive its caller, but a timed-out admission must stop waiting or fetching. */ +function withinAdmission(pending: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)) + if (signal.aborted) abort() + }) +} + +function positiveId(value: unknown): string | null { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value) + return typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value) ? value : null +} + +async function readGitHubJson(path: string, accessToken: string, signal: AbortSignal) { + signal.throwIfAborted() + const response = await fetch(`https://api.github.com${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + redirect: 'error', + cache: 'no-store', + signal, + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error('GitHub did not confirm current repository access') + } + return readResponseJsonWithLimit(response, { + maxBytes: GITHUB_READ_RESPONSE_MAX_BYTES, + label: 'GitHub repository authorization response', + signal, + }) +} + +/** A metadata response alone does not prove Contents permission; the Git ref endpoint does. */ +async function verifyRepository( + source: GitHubReadSource, + accountId: string, + accessToken: string, + signal: AbortSignal +): Promise { + if ( + !source.repository || + !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(source.repository) + ) + return false + if (source.repository.split('/').some((segment) => segment === '.' || segment === '..')) + return false + if (!positiveId(source.repositoryId)) return false + const path = `/repos/${source.repository.split('/').map(encodeURIComponent).join('/')}` + const repository = await readGitHubJson(path, accessToken, signal) + if ( + !isPlainRecord(repository) || + positiveId(repository.id) !== source.repositoryId || + !isPlainRecord(repository.owner) || + positiveId(repository.owner.id) !== accountId + ) + return false + const branch = source.branch?.trim() || repository.default_branch + if (typeof branch !== 'string' || !branch || branch.length > 1024) return false + if (branch.split('/').some((segment) => !segment || segment === '.' || segment === '..')) + return false + const reference = await readGitHubJson( + `${path}/git/ref/heads/${branch.split('/').map(encodeURIComponent).join('/')}`, + accessToken, + signal + ) + return ( + isPlainRecord(reference) && + reference.ref === `refs/heads/${branch}` && + isPlainRecord(reference.object) && + reference.object.type === 'commit' && + typeof reference.object.sha === 'string' && + /^[a-f0-9]{40,64}$/.test(reference.object.sha) + ) +} + +/** + * Proves current reader access before any installation-backed indexed content is selected. + * The caller supplies credentials already bound to the verified current member. All positive + * evidence and token reuse are local to this admission; failures grant nothing for that source. + */ +export async function resolveGitHubInstallationReadGrants(input: { + scope: ResourceScope + readers: readonly GitHubReaderCredential[] + connectorIds: readonly string[] + knowledgeBaseIds?: readonly string[] + signal?: AbortSignal +}): Promise { + input.signal?.throwIfAborted() + if (input.connectorIds.length > MAX_KNOWLEDGE_ACCESS_CANDIDATES) + throw new Error('Knowledge access candidates must be authorized in bounded pages') + if (!input.readers.length || !input.connectorIds.length || input.knowledgeBaseIds?.length === 0) + return [] + const readers = new Map(input.readers.map((reader) => [reader.credentialId, reader.subjectToken])) + const sources: GitHubReadSource[] = await db + .select({ + connectorId: knowledgeConnector.id, + contentCredentialId: knowledgeConnector.credentialId, + memberCredentialId: knowledgeConnectorMember.credentialId, + subjectToken: knowledgeConnectorMember.subjectToken, + repository: sql`left(${knowledgeConnector.sourceConfig}->>'repository', 202)`, + repositoryId: sql< + string | null + >`left(${knowledgeConnector.sourceConfig}->>'githubRepositoryId', 21)`, + branch: sql`left(${knowledgeConnector.sourceConfig}->>'branch', 1025)`, + }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) + .innerJoin( + knowledgeConnectorMember, + eq(knowledgeConnectorMember.connectorId, knowledgeConnector.id) + ) + .innerJoin( + credential, + and( + eq(credential.id, knowledgeConnectorMember.credentialId), + eq(credential.credentialGroupOptionId, knowledgeConnector.credentialGroupOptionId) + ) + ) + .innerJoin( + credentialGroupEnrollment, + and( + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId), + eq(credentialGroupEnrollment.credentialGroupId, knowledgeConnector.credentialGroupId) + ) + ) + .where( + and( + resourceScopeCondition(knowledgeBase, input.scope), + inArray(knowledgeConnector.id, [...new Set(input.connectorIds)]), + input.knowledgeBaseIds ? inArray(knowledgeBase.id, [...input.knowledgeBaseIds]) : undefined, + isNull(knowledgeBase.deletedAt), + eq(knowledgeConnector.connectorType, 'github'), + eq(knowledgeConnector.accessMode, 'members'), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + eq(knowledgeConnectorMember.status, 'active'), + inArray(knowledgeConnectorMember.credentialId, [...readers.keys()]), + sql`${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId'` + ) + ) + .orderBy(asc(knowledgeConnector.id), asc(knowledgeConnectorMember.id)) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + if (!sources.length) return [] + const contentCredentialIds = [ + ...new Set( + sources.flatMap((source) => (source.contentCredentialId ? [source.contentCredentialId] : [])) + ), + ] + if (!contentCredentialIds.length) return [] + const credentials = await db + .select({ + id: credential.id, + key: credential.encryptedServiceAccountKey, + installationId: credential.providerSubjectId, + accountId: credential.providerTenantId, + }) + .from(credential) + .where( + and( + inArray(credential.id, contentCredentialIds), + resourceScopeCondition(credential, input.scope), + eq(credential.type, 'service_account'), + eq(credential.providerId, GITHUB_INSTALLATION_PROVIDER_ID), + isNull(credential.revokedAt), + sql`octet_length(${credential.encryptedServiceAccountKey}) <= ${INSTALLATION_BINDING_MAX_BYTES}` + ) + ) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + const contentById = new Map(credentials.map((entry) => [entry.id, entry])) + const timeout = AbortSignal.timeout(GITHUB_READ_TIMEOUT_MS) + const admissionSignal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout + const installations = new Map>() + const tokens = new Map>() + const proofs = new Map>() + const grants = new Map() + let nextSource = 0 + const worker = async () => { + while (nextSource < sources.length && !admissionSignal.aborted) { + const source = sources[nextSource++] + const signal = AbortSignal.any([ + admissionSignal, + AbortSignal.timeout(GITHUB_READ_SOURCE_TIMEOUT_MS), + ]) + if (readers.get(source.memberCredentialId) !== source.subjectToken) continue + const content = source.contentCredentialId + ? contentById.get(source.contentCredentialId) + : undefined + if (!content?.key || !source.repositoryId) continue + try { + let installation = installations.get(content.id) + if (!installation) { + installation = (async () => { + const { decrypted } = await decryptSecret(content.key!) + const binding = parseGitHubInstallationBinding(JSON.parse(decrypted)) + if ( + binding.installationId !== content.installationId || + binding.accountId !== content.accountId + ) + throw new Error('GitHub installation credential identity mismatch') + await assertGitHubInstallationActive(binding, { signal: admissionSignal }) + return binding + })() + installations.set(content.id, installation) + } + const binding = await withinAdmission(installation, signal) + signal.throwIfAborted() + let token = tokens.get(source.memberCredentialId) + if (!token) { + token = resolveManagedOAuthToken({ + credentialId: source.memberCredentialId, + ...resourceScopeFields(input.scope), + expectedProviderId: 'github-repositories', + requiredScopes: [], + }).then(({ accessToken }) => { + if (!accessToken.startsWith('ghu_')) + throw new Error('A GitHub App user token is required') + return accessToken + }) + tokens.set(source.memberCredentialId, token) + } + const accessToken = await withinAdmission(token, signal) + signal.throwIfAborted() + const key = JSON.stringify([ + content.id, + source.memberCredentialId, + source.repositoryId, + source.repository, + source.branch, + ]) + let proof = proofs.get(key) + if (!proof) { + proof = (async () => { + if (!source.repository) return false + await assertGitHubInstallationRepositoryActive(binding, source.repository, { signal }) + return verifyRepository(source, binding.accountId, accessToken, signal) + })() + proofs.set(key, proof) + } + if (await withinAdmission(proof, signal)) + grants.set(source.connectorId, { + connectorId: source.connectorId, + contentCredentialId: content.id, + readerCredentialId: source.memberCredentialId, + readerSubjectToken: source.subjectToken, + repositoryId: source.repositoryId, + }) + } catch { + logger.warn('GitHub did not confirm current Search access', { + connectorId: source.connectorId, + }) + } + } + } + await Promise.all( + Array.from({ length: Math.min(GITHUB_READ_CONCURRENCY, sources.length) }, worker) + ) + input.signal?.throwIfAborted() + return [...grants.values()] +} diff --git a/apps/sim/lib/knowledge/access/predicate.postgres.test.ts b/apps/sim/lib/knowledge/access/predicate.postgres.test.ts index bcefa6889b6..5137d8404ef 100644 --- a/apps/sim/lib/knowledge/access/predicate.postgres.test.ts +++ b/apps/sim/lib/knowledge/access/predicate.postgres.test.ts @@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises' import type postgres from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createEnterpriseSearchMigrationFixture } from '@/lib/knowledge/__integration__/migration-fixture' +import type { GitHubInstallationReadGrant } from '@/lib/knowledge/access/types' vi.unmock('drizzle-orm') vi.unmock('@sim/db/schema') @@ -46,6 +47,15 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => { ) const [{ current_schema: schemaName }] = await client`SELECT current_schema()` await client.unsafe(approvalMigration.replaceAll('"public".', `"${schemaName}".`)) + await client.unsafe(` + ALTER TABLE knowledge_connector ADD COLUMN credential_id text, + ADD COLUMN source_config json NOT NULL DEFAULT '{}', + ADD COLUMN credential_group_id text, ADD COLUMN credential_group_option_id text; + ALTER TABLE credential ADD COLUMN revoked_at timestamp, ADD COLUMN credential_group_option_id text; + ALTER TABLE credential_group ADD COLUMN options jsonb NOT NULL DEFAULT '[]'; + ALTER TABLE credential_group_enrollment ADD COLUMN user_id text; + CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text); + `) expect(await readable(['ws'], 'before-migration')).toBe(true) await connection.unsafe("INSERT INTO document(id) VALUES ('old-writer-after-migration')") expect(await readable(['ws'], 'old-writer-after-migration')).toBe(true) @@ -64,9 +74,15 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => { ) }) - async function readable(tokens: string[], documentId: string, join = false): Promise { + async function readable( + tokens: string[], + documentId: string, + join = false, + githubInstallationGrants?: GitHubInstallationReadGrant[], + userId = 'reader' + ): Promise { const query = new PgDialect().sqlToQuery( - knowledgeAccessCondition({ kind: 'user', userId: 'reader', tokens }) + knowledgeAccessCondition({ kind: 'user', userId, tokens, githubInstallationGrants }) ) const values = query.params.map((value: unknown) => { if (typeof value === 'string' || typeof value === 'number') return value @@ -88,6 +104,105 @@ describe.runIf(Boolean(databaseUrl))('knowledge ACLs in PostgreSQL', () => { ) } + it('requires live GitHub proof and rechecks exact source, reader, credential and organization at every content query', async () => { + const token = 's:github-repositories:-:alice' + await connection.unsafe(` + INSERT INTO organization(id) VALUES ('github-org'); + INSERT INTO "user"(id,email,email_verified) VALUES ('reader','alice@example.com',true), ('bob','bob@example.com',true); + INSERT INTO member VALUES ('alice-membership','github-org','reader'), ('bob-membership','github-org','bob'); + INSERT INTO knowledge_base(id,organization_id,name,is_search_index) VALUES ('github-index','github-org','Search',true); + INSERT INTO credential_group(id,organization_id,name,status,options) + VALUES ('github-group','github-org','GitHub','active','[{"id":"github-option","status":"active"}]'); + INSERT INTO credential_group_enrollment(id,credential_group_id,email,status,user_id) + VALUES ('alice-enrollment','github-group','alice@example.com','completed','reader'); + INSERT INTO credential(id,organization_id,type,provider_id,provider_subject_id,provider_tenant_id,encrypted_service_account_key) + VALUES ('github-installation','github-org','service_account','github-app-installation','42','90','encrypted'); + INSERT INTO credential(id,organization_id,type,provider_id,provider_subject_id,authorization_app_id, + managed_oauth_status,granted_scopes,encrypted_oauth_token_set,granted_at,credential_group_enrollment_id,credential_group_option_id) + VALUES ('alice-github','github-org','managed_oauth','github-repositories','alice','github-app','active', + ARRAY[]::text[],'encrypted',now(),'alice-enrollment','github-option'); + INSERT INTO knowledge_connector(id,knowledge_base_id,connector_type,access_mode,credential_id,source_config,credential_group_id,credential_group_option_id) + VALUES ('github-source','github-index','github','members','github-installation', + '{"repository":"company/private","githubRepositoryId":"123"}','github-group','github-option'); + INSERT INTO knowledge_connector_member(id,organization_id,connector_id,subject_token,status,member_synced_through) + VALUES ('github-member','github-org','github-source','${token}','active',now()); + INSERT INTO document(id,knowledge_base_id,connector_id,acl) + VALUES ('github-document','github-index','github-source',ARRAY['${token}']); + INSERT INTO knowledge_document_observation(document_id,member_id,last_seen_at) + VALUES ('github-document','github-member',now()); + INSERT INTO embedding(id,document_id,content) VALUES ('github-chunk','github-document','private content'); + `) + const grants = [ + { + connectorId: 'github-source', + contentCredentialId: 'github-installation', + readerCredentialId: 'alice-github', + readerSubjectToken: token, + repositoryId: '123', + }, + ] + for (const join of [false, true]) { + expect(await readable([token], 'github-document', join)).toBe(false) + expect(await readable([token], 'github-document', join, grants)).toBe(true) + expect(await readable([token], 'github-document', join, grants, 'bob')).toBe(false) + expect( + await readable([token], 'github-document', join, [{ ...grants[0], repositoryId: '456' }]) + ).toBe(false) + expect( + await readable([token], 'github-document', join, [ + { ...grants[0], contentCredentialId: 'other-installation' }, + ]) + ).toBe(false) + } + await connection.unsafe( + "UPDATE credential_group_enrollment SET status='revoked' WHERE id='alice-enrollment'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe( + "UPDATE credential_group_enrollment SET status='completed' WHERE id='alice-enrollment'" + ) + await connection.unsafe( + "UPDATE credential_group_enrollment SET revoked_at=now() WHERE id='alice-enrollment'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe( + "UPDATE credential_group_enrollment SET revoked_at=NULL WHERE id='alice-enrollment'" + ) + await connection.unsafe( + "UPDATE credential SET provider_subject_id='bob' WHERE id='alice-github'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe( + "UPDATE credential SET provider_subject_id='alice' WHERE id='alice-github'" + ) + await connection.unsafe("UPDATE credential SET revoked_at=now() WHERE id='github-installation'") + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe("UPDATE credential SET revoked_at=NULL WHERE id='github-installation'") + await connection.unsafe( + "UPDATE credential SET provider_id='other-provider', type='oauth' WHERE id='github-installation'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + expect(await readable([token], 'github-document', true)).toBe(false) + await connection.unsafe( + "UPDATE credential SET provider_id='github-app-installation', type='service_account' WHERE id='github-installation'" + ) + await connection.unsafe( + "UPDATE knowledge_connector SET access_mode='admin' WHERE id='github-source'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe( + "UPDATE knowledge_connector SET access_mode='members' WHERE id='github-source'" + ) + await connection.unsafe("DELETE FROM member WHERE id='alice-membership'") + expect(await readable([token], 'github-document', true, grants)).toBe(false) + await connection.unsafe("INSERT INTO member VALUES ('alice-membership','github-org','reader')") + await connection.unsafe( + "UPDATE knowledge_connector SET credential_id=NULL WHERE id='github-source'" + ) + expect(await readable([token], 'github-document', true, grants)).toBe(false) + expect(await readable([token], 'github-document', true)).toBe(false) + }) + it('revokes every source of one integration without changing ACLs or another organization', async () => { await connection.unsafe("INSERT INTO organization(id) VALUES ('approval-org'), ('other-org')") await connection.unsafe( diff --git a/apps/sim/lib/knowledge/access/predicate.ts b/apps/sim/lib/knowledge/access/predicate.ts index f652ade1301..fb46dd48f86 100644 --- a/apps/sim/lib/knowledge/access/predicate.ts +++ b/apps/sim/lib/knowledge/access/predicate.ts @@ -1,13 +1,87 @@ import { + credential, + credentialGroup, + credentialGroupEnrollment, document, + knowledgeBase, knowledgeConnector, knowledgeConnectorMember, knowledgeDocumentObservation, + member, + user, } from '@sim/db/schema' import { type SQL, sql } from 'drizzle-orm' import { SOURCE_ACL_MAX_AGE_MS } from '@/lib/knowledge/access/freshness' import type { KnowledgeAccessScope, SystemAccessScope } from '@/lib/knowledge/access/types' import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integration-policy' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' + +/** Missing credentials or missing live evidence must never downgrade an installation source. */ +function githubInstallationAccessCondition(scope: KnowledgeAccessScope): SQL { + const grants = scope.kind === 'user' ? (scope.githubInstallationGrants ?? []) : [] + const allowed = + scope.kind !== 'user' || grants.length === 0 + ? sql`false` + : sql`EXISTS ( + SELECT 1 FROM (VALUES ${sql.join( + grants.map( + (grant) => sql`( + ${grant.connectorId}, ${grant.contentCredentialId}, ${grant.readerCredentialId}, ${grant.repositoryId}, ${grant.readerSubjectToken} + )` + ), + sql`, ` + )}) AS github_read_grant(connector_id, content_credential_id, reader_credential_id, repository_id, reader_subject_token) + JOIN ${credential} ON ${credential.id} = github_read_grant.content_credential_id + JOIN ${knowledgeBase} ON ${knowledgeBase.id} = ${knowledgeConnector.knowledgeBaseId} + WHERE github_read_grant.connector_id = ${knowledgeConnector.id} + AND github_read_grant.content_credential_id = ${knowledgeConnector.credentialId} + AND github_read_grant.repository_id = ${knowledgeConnector.sourceConfig}->>'githubRepositoryId' + AND ${knowledgeConnector.accessMode} = 'members' + AND ${knowledgeConnector.archivedAt} IS NULL AND ${knowledgeConnector.deletedAt} IS NULL + AND ${knowledgeBase.deletedAt} IS NULL + AND ${credential.type} = 'service_account' + AND ${credential.providerId} = ${GITHUB_INSTALLATION_PROVIDER_ID} + AND ${credential.revokedAt} IS NULL + AND ${credential.organizationId} IS NOT DISTINCT FROM ${knowledgeBase.organizationId} + AND ${credential.workspaceId} IS NOT DISTINCT FROM ${knowledgeBase.workspaceId} + AND (${knowledgeBase.organizationId} IS NULL OR EXISTS ( + SELECT 1 FROM ${member} WHERE ${member.organizationId} = ${knowledgeBase.organizationId} + AND ${member.userId} = ${scope.userId} + )) + AND EXISTS ( + SELECT 1 FROM ${credential} + JOIN ${credentialGroupEnrollment} ON ${credentialGroupEnrollment.id} = ${credential.credentialGroupEnrollmentId} + JOIN ${credentialGroup} ON ${credentialGroup.id} = ${credentialGroupEnrollment.credentialGroupId} + JOIN ${user} ON ${user.id} = ${scope.userId} + WHERE ${credential.id} = github_read_grant.reader_credential_id + AND ${credential.type} = 'managed_oauth' AND ${credential.providerId} = 'github-repositories' + AND ${credential.managedOauthStatus} = 'active' AND ${credential.revokedAt} IS NULL + AND ('s:github-repositories:' || COALESCE(NULLIF(${credential.providerTenantId}, ''), '-') || ':' || ${credential.providerSubjectId}) = github_read_grant.reader_subject_token + AND ${credential.organizationId} IS NOT DISTINCT FROM ${knowledgeBase.organizationId} + AND ${credential.workspaceId} IS NOT DISTINCT FROM ${knowledgeBase.workspaceId} + AND ${credentialGroup.organizationId} IS NOT DISTINCT FROM ${knowledgeBase.organizationId} + AND ${credentialGroup.workspaceId} IS NOT DISTINCT FROM ${knowledgeBase.workspaceId} + AND ${credentialGroup.status} = 'active' + AND ${credentialGroup.id} = ${knowledgeConnector.credentialGroupId} + AND ${credential.credentialGroupOptionId} = ${knowledgeConnector.credentialGroupOptionId} + AND ${credentialGroupEnrollment.status} IN ('in_progress', 'completed') + AND ${credentialGroupEnrollment.revokedAt} IS NULL + AND ${user.emailVerified} = true + AND ((${knowledgeBase.organizationId} IS NOT NULL AND ${credentialGroupEnrollment.userId} = ${scope.userId}) + OR (${knowledgeBase.workspaceId} IS NOT NULL AND ${credentialGroupEnrollment.email} = lower(btrim(${user.email})))) + AND EXISTS (SELECT 1 FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'id' = ${credential.credentialGroupOptionId} AND option->>'status' = 'active') + ) + )` + return sql`( + ${knowledgeConnector.connectorType} IS DISTINCT FROM 'github' + OR (NOT (${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId') AND NOT EXISTS ( + SELECT 1 FROM ${credential} WHERE ${credential.id} = ${knowledgeConnector.credentialId} + AND ${credential.providerId} = ${GITHUB_INSTALLATION_PROVIDER_ID} + )) + OR ${allowed} + )` +} /** * The single read-side access predicate: the document's ACL overlaps the @@ -23,6 +97,27 @@ import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integra * partial listings confirm only the documents actually observed. */ export function knowledgeAccessCondition(scope: KnowledgeAccessScope | SystemAccessScope): SQL { + return storedKnowledgeAccessCondition( + scope, + scope.kind === 'system' ? sql`true` : githubInstallationAccessCondition(scope) + ) +} + +/** + * Stored access for fixed identifier/rank candidate projections only. Candidate identities + * must pass live source authorization and knowledgeAccessCondition before content, names, + * tags, counts, provenance, or model input are selected or returned. + */ +export function knowledgeMetadataCandidateAccessCondition( + scope: KnowledgeAccessScope | SystemAccessScope +): SQL { + return storedKnowledgeAccessCondition(scope, sql`true`) +} + +function storedKnowledgeAccessCondition( + scope: KnowledgeAccessScope | SystemAccessScope, + liveSourceAccess: SQL +): SQL { if (scope.kind === 'system') return sql`true` if (scope.tokens.length === 0) return sql`false` const tokens = textArrayLiteral(scope.tokens) @@ -39,6 +134,7 @@ export function knowledgeAccessCondition(scope: KnowledgeAccessScope | SystemAcc SELECT 1 FROM ${knowledgeConnector} WHERE ${knowledgeConnector.id} = ${document.connectorId} AND ${searchIntegrationAccessCondition()} + AND ${liveSourceAccess} AND ( (${knowledgeConnector.accessMode} = 'workspace' AND ${document.acl} = ARRAY['ws']::text[]) OR (${document.acl} <> ARRAY['ws']::text[] AND ( diff --git a/apps/sim/lib/knowledge/access/scope.test.ts b/apps/sim/lib/knowledge/access/scope.test.ts index c2ddde49485..d1f936e2004 100644 --- a/apps/sim/lib/knowledge/access/scope.test.ts +++ b/apps/sim/lib/knowledge/access/scope.test.ts @@ -6,9 +6,10 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s import { eq, inArray } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAvailability, mockCheckWorkspaceAccess } = vi.hoisted(() => ({ +const { mockAvailability, mockCheckWorkspaceAccess, mockGitHubReadGrants } = vi.hoisted(() => ({ mockAvailability: vi.fn(async () => ({ memberScoped: true, sourceMirrored: true })), mockCheckWorkspaceAccess: vi.fn(async () => ({ hasAccess: true })), + mockGitHubReadGrants: vi.fn(async () => []), })) vi.mock('@/lib/knowledge/access/availability', () => ({ @@ -17,6 +18,9 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) +vi.mock('@/lib/knowledge/access/github-installation', () => ({ + resolveGitHubInstallationReadGrants: mockGitHubReadGrants, +})) import { createKnowledgeAccessProvider, @@ -453,6 +457,46 @@ describe('organization document ACL scope', () => { vi.clearAllMocks() resetDbChainMock() }) + it('live-checks only the current member’s GitHub credentials within the canonical selected index', async () => { + queueTableRows(schemaMock.member, [{ id: 'membership-1' }]) + queueSubjects([ + { + email: 'viewer@example.com', + credentialId: 'personal-github', + providerId: 'github-repositories', + providerSubjectId: '42', + providerTenantId: null, + }, + ]) + const provider = createKnowledgeAccessProvider(SESSION, { + ...organization, + knowledgeBaseIds: ['index-1'], + }) + expect(await provider.get()).not.toHaveProperty('githubInstallationGrants') + expect(mockGitHubReadGrants).not.toHaveBeenCalled() + const scope = await provider.getForConnectors(['source-after-100']) + expect(mockGitHubReadGrants).toHaveBeenCalledWith({ + scope: { kind: 'organization', organizationId: 'org-1' }, + readers: [{ credentialId: 'personal-github', subjectToken: 's:github-repositories:-:42' }], + knowledgeBaseIds: ['index-1'], + connectorIds: ['source-after-100'], + signal: undefined, + }) + expect(scope).toMatchObject({ githubInstallationGrants: [] }) + }) + it('does not live-check retained provider credentials after organization removal', async () => { + queueTableRows(schemaMock.member, []) + queueSubjects([ + { + credentialId: 'personal-github', + providerId: 'github-repositories', + providerSubjectId: '42', + providerTenantId: null, + }, + ]) + expect(await resolveKnowledgeAccessScope(SESSION, organization)).toMatchObject({ tokens: [] }) + expect(mockGitHubReadGrants).not.toHaveBeenCalled() + }) it('uses current organization membership and org baseline without any workspace membership', async () => { queueTableRows(schemaMock.member, [{ id: 'membership-1' }]) queueSubjects([ diff --git a/apps/sim/lib/knowledge/access/scope.ts b/apps/sim/lib/knowledge/access/scope.ts index 9496b1e72e2..63ad6d25dd3 100644 --- a/apps/sim/lib/knowledge/access/scope.ts +++ b/apps/sim/lib/knowledge/access/scope.ts @@ -4,7 +4,9 @@ import { credential, credentialGroup, credentialGroupEnrollment, + document, foldedEmail, + knowledgeBase, knowledgeExternalGroup, knowledgeExternalGroupMember, member, @@ -12,7 +14,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { and, eq, gte, inArray, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, isNull, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' @@ -23,6 +25,11 @@ import { EXTERNAL_GROUP_STALE_AFTER_MS, emailDomain, } from '@/lib/knowledge/access/external-groups' +import { + type GitHubReaderCredential, + resolveGitHubInstallationReadGrants, +} from '@/lib/knowledge/access/github-installation' +import { knowledgeMetadataCandidateAccessCondition } from '@/lib/knowledge/access/predicate' import { groupToken, sortAccessTokens, @@ -32,6 +39,7 @@ import { import { type KnowledgeAccessProvider, type KnowledgeAccessScope, + MAX_KNOWLEDGE_ACCESS_CANDIDATES, ORGANIZATION_ACCESS_TOKENS, WORKSPACE_ACCESS_TOKENS, type WorkspaceAccessScope, @@ -79,7 +87,7 @@ async function loadExternalGroupTokens( ): Promise { /** * A query of its own rather than a fourth join on the credential query in - * `loadUserAccessTokens`: + * `loadUserAccess`: * that one already fans out per managed credential, and joining groups onto * it would multiply the two — every credential row repeated for every group. * Two indexed reads cost less than one cross product. @@ -120,6 +128,9 @@ export interface KnowledgeAccessScopeContext { /** Exactly one workspace or organization owner is required at resolution. */ workspaceId?: string organizationId?: string + /** Canonical bases already selected by the application resolver, never caller assertions. */ + knowledgeBaseIds?: readonly string[] + signal?: AbortSignal } /** @@ -131,10 +142,10 @@ export interface KnowledgeAccessScopeContext { * really owns it. Nothing here is cached: revoking a credential or leaving a * group is visible on the next read. */ -async function loadUserAccessTokens( +async function loadUserAccess( userId: string, context: KnowledgeAccessScopeContext -): Promise { +): Promise<{ tokens: readonly string[]; githubReaders?: GitHubReaderCredential[] }> { const { workspaceId, organizationId } = context const scope = resourceScopeFromOwner(context) const baseline = organizationId ? ORGANIZATION_ACCESS_TOKENS : WORKSPACE_ACCESS_TOKENS @@ -150,10 +161,10 @@ async function loadUserAccessTokens( .from(member) .where(and(eq(member.organizationId, scope.organizationId), eq(member.userId, userId))) .limit(1) - if (!membership) return [] + if (!membership) return { tokens: [] } } else { const workspaceAccess = await checkWorkspaceAccess(scope.workspaceId, userId) - if (!workspaceAccess.hasAccess) return [] + if (!workspaceAccess.hasAccess) return { tokens: [] } } /** * An identity token only counts where permission-aware knowledge is on, so @@ -164,13 +175,14 @@ async function loadUserAccessTokens( */ const availability = await resolveKnowledgeAccessAvailability(context) if (!availability.memberScoped && !availability.sourceMirrored) { - return [...baseline] + return { tokens: [...baseline] } } const rows = await db .select({ emailIsAmbiguous: emailHeldByAnotherAccount, email: foldedEmail(user.email), + credentialId: credential.id, providerId: credential.providerId, providerTenantId: credential.providerTenantId, providerSubjectId: credential.providerSubjectId, @@ -218,14 +230,18 @@ async function loadUserAccessTokens( userId, workspaceId, }) - return [...baseline] + return { tokens: [...baseline] } } const identityTokens = new Set() + const githubReaders: GitHubReaderCredential[] = [] for (const row of rows) { if (!availability.memberScoped || !row.providerSubjectId) continue try { - identityTokens.add(subjectToken(row)) + const token = subjectToken(row) + identityTokens.add(token) + if (row.providerId === 'github-repositories' && row.credentialId) + githubReaders.push({ credentialId: row.credentialId, subjectToken: token }) } catch (error) { logger.warn('Skipping malformed managed credential subject', { userId, @@ -256,7 +272,10 @@ async function loadUserAccessTokens( } } - return sortAccessTokens(new Set([...baseline, ...identityTokens])) + return { + tokens: sortAccessTokens(new Set([...baseline, ...identityTokens])), + githubReaders, + } } /** @@ -270,6 +289,13 @@ export async function resolveKnowledgeAccessScope( principal: Principal, context: KnowledgeAccessScopeContext ): Promise { + return (await resolveKnowledgeIdentity(principal, context)).access +} + +async function resolveKnowledgeIdentity( + principal: Principal, + context: KnowledgeAccessScopeContext +): Promise<{ access: KnowledgeAccessScope; githubReaders: readonly GitHubReaderCredential[] }> { if (principal.kind === 'credential_group_enrollment') { throw new OrchestrationError( 'forbidden', @@ -281,12 +307,12 @@ export async function resolveKnowledgeAccessScope( if (subject?.kind !== 'sim_user') { if (context.organizationId) throw new OrchestrationError('forbidden', 'Organization search requires a user subject') - return WORKSPACE_ACCESS_SCOPE + return { access: WORKSPACE_ACCESS_SCOPE, githubReaders: [] } } + const { tokens, githubReaders = [] } = await loadUserAccess(subject.userId, context) return { - kind: 'user', - userId: subject.userId, - tokens: await loadUserAccessTokens(subject.userId, context), + access: { kind: 'user', userId: subject.userId, tokens }, + githubReaders, } } @@ -300,7 +326,7 @@ export async function resolveUserKnowledgeAccessScope( userId: string, workspaceId: string | undefined ): Promise { - return { kind: 'user', userId, tokens: await loadUserAccessTokens(userId, { workspaceId }) } + return { kind: 'user', userId, tokens: (await loadUserAccess(userId, { workspaceId })).tokens } } /** Memoises {@link resolveKnowledgeAccessScope} for one operation; a failed lookup is retried on the next call. */ @@ -308,14 +334,71 @@ export function createKnowledgeAccessProvider( principal: Principal, context: KnowledgeAccessScopeContext ): KnowledgeAccessProvider { - let pending: Promise | undefined - return { - get() { - pending ??= resolveKnowledgeAccessScope(principal, context).catch((error: unknown) => { - pending = undefined - throw error - }) - return pending + let pending: ReturnType | undefined + const identity = () => { + pending ??= resolveKnowledgeIdentity(principal, context).catch((error: unknown) => { + pending = undefined + throw error + }) + return pending + } + const boundedIds = (ids: readonly string[]) => { + if (ids.length > MAX_KNOWLEDGE_ACCESS_CANDIDATES) + throw new Error('Knowledge access candidates must be authorized in bounded pages') + return [...new Set(ids)] + } + const provider: KnowledgeAccessProvider = { + async get() { + return (await identity()).access + }, + async getForConnectors(connectorIds, signal) { + const ids = boundedIds(connectorIds) + const cancellation = + context.signal && signal + ? AbortSignal.any([context.signal, signal]) + : (signal ?? context.signal) + cancellation?.throwIfAborted() + const { access, githubReaders } = await identity() + cancellation?.throwIfAborted() + if (access.kind !== 'user' || !githubReaders.length || !ids.length) return access + return { + ...access, + githubInstallationGrants: await resolveGitHubInstallationReadGrants({ + scope: resourceScopeFromOwner(context), + readers: githubReaders, + knowledgeBaseIds: context.knowledgeBaseIds, + connectorIds: ids, + signal: cancellation, + }), + } + }, + async getForDocuments(documentIds, signal) { + const ids = boundedIds(documentIds) + signal?.throwIfAborted() + context.signal?.throwIfAborted() + const { access, githubReaders } = await identity() + if (access.kind !== 'user' || !githubReaders.length || !ids.length) return access + const candidates = await db + .select({ connectorId: document.connectorId }) + .from(document) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, document.knowledgeBaseId)) + .where( + and( + inArray(document.id, ids), + resourceScopeCondition(knowledgeBase, resourceScopeFromOwner(context)), + context.knowledgeBaseIds + ? inArray(knowledgeBase.id, [...context.knowledgeBaseIds]) + : undefined, + isNull(knowledgeBase.deletedAt), + knowledgeMetadataCandidateAccessCondition(access) + ) + ) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + return provider.getForConnectors( + candidates.flatMap((candidate) => (candidate.connectorId ? [candidate.connectorId] : [])), + signal + ) }, } + return provider } diff --git a/apps/sim/lib/knowledge/access/types.ts b/apps/sim/lib/knowledge/access/types.ts index c8aa195f50c..400486649a9 100644 --- a/apps/sim/lib/knowledge/access/types.ts +++ b/apps/sim/lib/knowledge/access/types.ts @@ -39,6 +39,16 @@ export interface UserAccessScope { * they belong to. */ tokens: readonly string[] + /** Live user-token evidence, scoped to the installation source's immutable repository. */ + githubInstallationGrants?: readonly GitHubInstallationReadGrant[] +} + +export interface GitHubInstallationReadGrant { + connectorId: string + contentCredentialId: string + readerCredentialId: string + readerSubjectToken: string + repositoryId: string } /** @@ -64,8 +74,19 @@ export type MirroredDocumentAcl = readonly string[] | SourceDocumentAcl */ export interface KnowledgeAccessProvider { get(): Promise + getForConnectors( + connectorIds: readonly string[], + signal?: AbortSignal + ): Promise + getForDocuments( + documentIds: readonly string[], + signal?: AbortSignal + ): Promise } +/** Two existing search legs each contribute at most 200 candidates to one authorization batch. */ +export const MAX_KNOWLEDGE_ACCESS_CANDIDATES = 400 + declare const systemAccessScopeBrand: unique symbol /** diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index bc4163d815d..ed5daa0312e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -26,6 +26,7 @@ import { validateConnectorSourceConfig, } from '@/lib/knowledge/application/connectors' import { resolveActiveKnowledgeConnectorContext } from '@/lib/knowledge/application/contexts' +import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { type ConnectorAccessMode, @@ -198,7 +199,20 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ 'Search sources must support per-person access or source permissions' ) } - const sourceConfig = connector.sourceConfig as Record + const previousConfig = connector.sourceConfig as Record + const sourceConfig = await prepareGitHubInstallationSource({ + connectorType: connector.connectorType, + credentialId: + input.credentialId === undefined && input.accessMode === connector.accessMode + ? connector.credentialId + : input.credentialId, + organizationId: context.organizationId, + isSearchIndex: context.knowledgeBase.isSearchIndex === true, + accessMode: input.accessMode, + actingUserId, + sourceConfig: previousConfig, + previousConfig, + }) let target: ConnectorAccessTarget if (input.accessMode === 'members') { diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index 853f08ca224..e8adeb13283 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -48,6 +48,7 @@ import { resolveActiveKnowledgeResourceContext, resolveKnowledgeWorkspaceContext, } from '@/lib/knowledge/application/contexts' +import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { type ConnectorAccessMode, @@ -765,13 +766,23 @@ async function executeCreateKnowledgeConnector( }) } } + const sourceConfig = await prepareGitHubInstallationSource({ + connectorType: input.connectorType, + credentialId: input.credentialId, + organizationId: context.organizationId, + isSearchIndex: context.knowledgeBase.isSearchIndex === true, + accessMode: input.accessMode ?? 'workspace', + actingUserId, + sourceConfig: membersBinding?.sourceConfig ?? input.sourceConfig, + }) + if (membersBinding) membersBinding = { ...membersBinding, sourceConfig } const outcome = await performCreateKnowledgeConnector({ knowledgeBase: connectorTarget(context), connectorType: input.connectorType, credentialId: input.credentialId, apiKey: input.apiKey, /** Members mode stores the config with its listing caps cleared. */ - sourceConfig: membersBinding?.sourceConfig ?? input.sourceConfig, + sourceConfig, syncIntervalMinutes: input.syncIntervalMinutes, membersBinding, accessMode: input.accessMode, @@ -787,7 +798,7 @@ async function executeCreateKnowledgeConnector( requestId, auth: connectorMeta.auth, accessMode: input.accessMode ?? 'workspace', - sourceConfig: input.sourceConfig, + sourceConfig, }), userId: actingUserId, source: input.source ?? 'agent', @@ -928,6 +939,17 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ knowledgeBase: connectorTarget(context), connectorId: context.connectorId, updates: input.updates, + prepareSourceConfig: (connector, sourceConfig) => + prepareGitHubInstallationSource({ + connectorType: connector.connectorType, + credentialId: connector.credentialId, + organizationId: context.organizationId, + isSearchIndex: context.knowledgeBase.isSearchIndex === true, + accessMode: connector.accessMode, + actingUserId, + sourceConfig, + previousConfig: connector.sourceConfig as Record, + }), resolveBillingAttribution: () => { const workspaceId = context.workspaceId return ( diff --git a/apps/sim/lib/knowledge/application/contexts.test.ts b/apps/sim/lib/knowledge/application/contexts.test.ts index 04eb468265e..f7752fcd4c1 100644 --- a/apps/sim/lib/knowledge/application/contexts.test.ts +++ b/apps/sim/lib/knowledge/application/contexts.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -15,6 +16,8 @@ const mocks = vi.hoisted(() => ({ loadWorkspaceIncludingArchived: vi.fn(), createAccessProvider: vi.fn(() => ({ get: async () => ({ kind: 'workspace', tokens: ['pub', 'ws'] }), + getForDocuments: vi.fn(async () => ({ kind: 'workspace', tokens: ['pub', 'ws'] })), + getForConnectors: vi.fn(async () => ({ kind: 'workspace', tokens: ['pub', 'ws'] })), })), })) @@ -48,6 +51,7 @@ vi.mock('@/lib/workspaces/application/workspace-context', () => ({ import { loadKnowledgeWorkspaceAuthorizationContext, resolveActiveKnowledgeBaseContext, + resolveActiveKnowledgeChunkContext, resolveActiveKnowledgeConnectorContext, resolveActiveKnowledgeResourceContext, resolveActiveKnowledgeTagContext, @@ -67,6 +71,7 @@ const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'sess describe('knowledge application contexts', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) mocks.getKnowledgeBaseWithCounts.mockResolvedValue({ ...knowledgeBase, @@ -77,6 +82,24 @@ describe('knowledge application contexts', () => { mocks.loadWorkspaceIncludingArchived.mockResolvedValue(workspace) }) + it('selects only chunk identity before document authorization and never hydrates a denied chunk', async () => { + queueTableRows(schemaMock.embedding, [ + { id: 'chunk-1', documentId: 'document-1', knowledgeBaseId: 'knowledge-1' }, + ]) + mocks.getDocumentById.mockResolvedValueOnce(null) + await expect( + resolveActiveKnowledgeChunkContext( + { chunkId: 'chunk-1', documentId: 'document-1', knowledgeBaseId: 'knowledge-1' }, + principal + ) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.select).toHaveBeenCalledExactlyOnceWith({ + id: schemaMock.embedding.id, + documentId: schemaMock.embedding.documentId, + knowledgeBaseId: schemaMock.embedding.knowledgeBaseId, + }) + }) + it('resolves child-resource context without loading display counts', async () => { const context = await resolveActiveKnowledgeResourceContext( { knowledgeBaseId: 'knowledge-1', assertedWorkspaceId: 'workspace-1' }, @@ -87,6 +110,7 @@ describe('knowledge application contexts', () => { expect(mocks.getKnowledgeBaseWithCounts).not.toHaveBeenCalled() expect(mocks.createAccessProvider).toHaveBeenCalledWith(principal, { workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], }) }) diff --git a/apps/sim/lib/knowledge/application/contexts.ts b/apps/sim/lib/knowledge/application/contexts.ts index 1738dbd70e5..813571ece43 100644 --- a/apps/sim/lib/knowledge/application/contexts.ts +++ b/apps/sim/lib/knowledge/application/contexts.ts @@ -1,11 +1,12 @@ import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' -import { embedding, organization } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { document as documentTable, embedding, organization } from '@sim/db/schema' +import { and, eq, getTableColumns } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' +import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' -import type { KnowledgeAccessProvider } from '@/lib/knowledge/access/types' +import type { KnowledgeAccessProvider, KnowledgeAccessScope } from '@/lib/knowledge/access/types' import type { KnowledgeAuthorizationContext, KnowledgeOrganizationAuthorizationContext, @@ -65,6 +66,24 @@ interface KnowledgeAccessBearingContext { access: KnowledgeAccessProvider } +/** A canonical child reuses only its own bounded source admission throughout the operation. */ +function narrowKnowledgeAccessProvider( + provider: KnowledgeAccessProvider, + resolve: () => Promise +): KnowledgeAccessProvider { + let pending: Promise | undefined + return { + ...provider, + get() { + pending ??= resolve().catch((error: unknown) => { + pending = undefined + throw error + }) + return pending + }, + } +} + export interface ActiveKnowledgeBaseContext extends KnowledgeWorkspaceContext, KnowledgeAccessBearingContext { @@ -164,7 +183,10 @@ export async function resolveActiveKnowledgeBaseContext( ...workspaceContext, knowledgeBaseId: knowledgeBase.id, knowledgeBase, - access: createKnowledgeAccessProvider(principal, { workspaceId: knowledgeBase.workspaceId }), + access: createKnowledgeAccessProvider(principal, { + workspaceId: knowledgeBase.workspaceId, + knowledgeBaseIds: [knowledgeBase.id], + }), } } @@ -185,7 +207,10 @@ export async function resolveActiveKnowledgeBaseInWorkspace( ...workspaceContext, knowledgeBaseId: knowledgeBase.id, knowledgeBase, - access: createKnowledgeAccessProvider(principal, { workspaceId: workspaceContext.workspaceId }), + access: createKnowledgeAccessProvider(principal, { + workspaceId: workspaceContext.workspaceId, + knowledgeBaseIds: [knowledgeBase.id], + }), } } @@ -251,7 +276,10 @@ export async function resolveActiveKnowledgeResourceContext( ...owner, knowledgeBaseId: knowledgeBase.id, knowledgeBase, - access: createKnowledgeAccessProvider(principal, owner), + access: createKnowledgeAccessProvider(principal, { + ...owner, + knowledgeBaseIds: [knowledgeBase.id], + }), } } if (!knowledgeBase.workspaceId) { @@ -263,7 +291,10 @@ export async function resolveActiveKnowledgeResourceContext( ...workspaceContext, knowledgeBaseId: knowledgeBase.id, knowledgeBase, - access: createKnowledgeAccessProvider(principal, { workspaceId: knowledgeBase.workspaceId }), + access: createKnowledgeAccessProvider(principal, { + workspaceId: knowledgeBase.workspaceId, + knowledgeBaseIds: [knowledgeBase.id], + }), } } @@ -277,14 +308,18 @@ export async function resolveActiveKnowledgeDocumentContext( principal: Principal ): Promise { const context = await resolveActiveKnowledgeResourceContext(input, principal) + const access = narrowKnowledgeAccessProvider(context.access, () => + context.access.getForDocuments([input.documentId]) + ) const document = await getKnowledgeDocument( context.knowledgeBaseId, input.documentId, - await context.access.get() + await access.get() ) if (!document) throw new OrchestrationError('not_found', 'Document not found') return { ...context, + access, documentId: document.id, document, } @@ -307,12 +342,16 @@ export async function resolveCanonicalActiveKnowledgeDocumentContext( principal: Principal ): Promise { const context = await resolveActiveKnowledgeResourceContext(input, principal) - const document = await getKnowledgeDocumentById(input.documentId, await context.access.get()) + const access = narrowKnowledgeAccessProvider(context.access, () => + context.access.getForDocuments([input.documentId]) + ) + const document = await getKnowledgeDocumentById(input.documentId, await access.get()) if (!document || document.knowledgeBaseId !== context.knowledgeBaseId) { throw new OrchestrationError('not_found', 'Document not found') } return { ...context, + access, documentId: document.id, document, } @@ -328,15 +367,32 @@ export async function resolveActiveKnowledgeChunkContext( }, principal: Principal ): Promise { - const [chunk] = await db - .select() + const [reference] = await db + .select({ + id: embedding.id, + documentId: embedding.documentId, + knowledgeBaseId: embedding.knowledgeBaseId, + }) .from(embedding) .where(and(eq(embedding.id, input.chunkId), eq(embedding.documentId, input.documentId))) .limit(1) - if (!chunk || chunk.knowledgeBaseId !== input.knowledgeBaseId) { + if (!reference || reference.knowledgeBaseId !== input.knowledgeBaseId) { throw new OrchestrationError('not_found', 'Chunk not found') } const context = await resolveCanonicalActiveKnowledgeDocumentContext(input, principal) + const [chunk] = await db + .select(getTableColumns(embedding)) + .from(embedding) + .innerJoin(documentTable, eq(documentTable.id, embedding.documentId)) + .where( + and( + eq(embedding.id, reference.id), + eq(embedding.documentId, context.documentId), + knowledgeAccessCondition(await context.access.get()) + ) + ) + .limit(1) + if (!chunk) throw new OrchestrationError('not_found', 'Chunk not found') return { ...context, chunkId: chunk.id, @@ -394,11 +450,15 @@ export async function resolveActiveKnowledgeConnectorContext( { knowledgeBaseId: connector.knowledgeBaseId, assertedWorkspaceId: input.assertedWorkspaceId, + assertedOrganizationId: input.assertedOrganizationId, }, principal ) return { ...context, + access: narrowKnowledgeAccessProvider(context.access, () => + context.access.getForConnectors([connector.id]) + ), connectorId: connector.id, connector, } diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index b55db469cfa..751ea4c7426 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -115,7 +115,11 @@ import { } from '@/lib/knowledge/application/documents' /** Every mocked context carries the workspace read scope the resolvers would attach. */ -const knowledgeAccess = { get: async () => WORKSPACE_ACCESS_SCOPE } +const knowledgeAccess = { + get: async () => WORKSPACE_ACCESS_SCOPE, + getForDocuments: async () => WORKSPACE_ACCESS_SCOPE, + getForConnectors: async () => WORKSPACE_ACCESS_SCOPE, +} const context = { access: knowledgeAccess, diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 508f6e6eacf..90e9e5f04cd 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -11,7 +11,6 @@ import { import { authorizeWorkspaceOperation } from '@/lib/core/application' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { @@ -61,6 +60,7 @@ import { performUploadKnowledgeDocument, performUploadKnowledgeDocuments, } from '@/lib/knowledge/orchestration/documents' +import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import type { KnowledgeDocumentWriteSecretProvenance } from '@/lib/knowledge/secret-provenance' import { type KnowledgeTagNameFilter, @@ -329,7 +329,7 @@ export const listKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ tagFilters: tagFilters.length > 0 ? tagFilters : undefined, }, generateRequestId(), - await context.access.get() + context.organizationId ? context.access : await context.access.get() ) return { ...result, @@ -673,36 +673,27 @@ export const upsertKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ * Only a document the caller may read counts as the one being replaced: * a restricted document is neither confirmed to exist nor replaced. */ - const access = await context.access.get() + const lookupConditions = [ + eq(documentTable.knowledgeBaseId, context.knowledgeBaseId), + isNull(documentTable.deletedAt), + input.documentId + ? eq(documentTable.id, input.documentId) + : eq(documentTable.filename, input.filename), + ] let existingDocumentId: string | null = null - if (input.documentId) { - const [existing] = await db - .select({ id: documentTable.id }) - .from(documentTable) - .where( - and( - eq(documentTable.id, input.documentId), - eq(documentTable.knowledgeBaseId, context.knowledgeBaseId), - isNull(documentTable.deletedAt), - knowledgeAccessCondition(access) - ) - ) - .limit(1) - existingDocumentId = existing?.id ?? null - } else { + for await (const accessCondition of knowledgeReadAccessBatches( + context.organizationId ? context.access : await context.access.get(), + lookupConditions + )) { const [existing] = await db .select({ id: documentTable.id }) .from(documentTable) - .where( - and( - eq(documentTable.filename, input.filename), - eq(documentTable.knowledgeBaseId, context.knowledgeBaseId), - isNull(documentTable.deletedAt), - knowledgeAccessCondition(access) - ) - ) + .where(and(...lookupConditions, accessCondition)) .limit(1) - existingDocumentId = existing?.id ?? null + if (existing) { + existingDocumentId = existing.id + break + } } const requestId = generateRequestId() const createdDocuments = await createDocumentRecords( @@ -728,7 +719,7 @@ export const upsertKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ context.knowledgeBaseId, existingDocumentId, requestId, - access + await context.access.getForDocuments([existingDocumentId]) ) } catch (error) { /** @@ -889,7 +880,7 @@ export const bulkDeleteKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ canonical.knowledgeBaseId, canonical.documentId, generateRequestId(), - await context.access.get() + await canonical.access.get() ) deletedDocuments.push({ id: canonical.documentId, @@ -1036,7 +1027,7 @@ export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ context.knowledgeBaseId, input.operation, input.enabledFilter, - await context.access.get(), + context.organizationId ? context.access : await context.access.get(), generateRequestId() ) : input.documentIds?.length @@ -1044,7 +1035,7 @@ export const bulkUpdateKnowledgeDocuments = defineAuthorizedKnowledgeUseCase({ context.knowledgeBaseId, input.operation, input.documentIds, - await context.access.get(), + context.organizationId ? context.access : await context.access.get(), generateRequestId() ) : null diff --git a/apps/sim/lib/knowledge/application/github-installation-source.test.ts b/apps/sim/lib/knowledge/application/github-installation-source.test.ts new file mode 100644 index 00000000000..5eaee58ae6c --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installation-source.test.ts @@ -0,0 +1,153 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ + access: vi.fn(), + canUse: vi.fn(), + decrypt: vi.fn(), + parse: vi.fn(), + repository: vi.fn(), +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: m.access, + canUseCredential: m.canUse, +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: m.decrypt })) +vi.mock('@/lib/oauth/github-installation', () => ({ + parseGitHubInstallationBinding: m.parse, + resolveGitHubInstallationRepository: m.repository, +})) + +import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' + +const installed = { + id: 'installation-credential', + providerId: 'github-app-installation', + organizationId: 'org', + workspaceId: null, + type: 'service_account', + revokedAt: null, + encryptedServiceAccountKey: 'encrypted-binding', + providerSubjectId: '42', + providerTenantId: '7', +} +const input = { + connectorType: 'github', + credentialId: installed.id, + organizationId: 'org', + isSearchIndex: true, + accessMode: 'members', + actingUserId: 'admin', + sourceConfig: { repository: 'example/private' }, +} + +beforeEach(() => { + vi.clearAllMocks() + m.access.mockResolvedValue({ credential: installed }) + m.canUse.mockReturnValue(true) + m.decrypt.mockResolvedValue({ decrypted: '{}' }) + m.parse.mockReturnValue({ installationId: '42', accountId: '7' }) + m.repository.mockResolvedValue({ id: '123', fullName: 'example/private', defaultBranch: 'main' }) +}) + +describe('GitHub installation source identity', () => { + it('persists only the provider-attested repository ID even when the browser supplies another', async () => { + await expect( + prepareGitHubInstallationSource({ + ...input, + sourceConfig: { ...input.sourceConfig, githubRepositoryId: '999' }, + }) + ).resolves.toEqual({ repository: 'example/private', githubRepositoryId: '123' }) + expect(m.access).toHaveBeenCalledWith(installed.id, 'admin') + }) + it.each([ + { organizationId: undefined }, + { isSearchIndex: false }, + { accessMode: 'admin' }, + { accessMode: 'workspace' }, + ])('requires organization Search and member access: %j', async (change) => { + await expect(prepareGitHubInstallationSource({ ...input, ...change })).rejects.toMatchObject({ + code: 'validation', + }) + expect(m.repository).not.toHaveBeenCalled() + }) + it.each([ + { organizationId: 'other-org' }, + { workspaceId: 'workspace' }, + { type: 'oauth' }, + { revokedAt: new Date() }, + { encryptedServiceAccountKey: null }, + ])('refuses unusable or cross-scope installation credentials: %j', async (change) => { + m.access.mockResolvedValue({ credential: { ...installed, ...change } }) + await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(m.decrypt).not.toHaveBeenCalled() + }) + it('refuses credentials the acting user cannot use', async () => { + m.canUse.mockReturnValue(false) + await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ + code: 'forbidden', + }) + }) + it('bounds encrypted binding data before decryption', async () => { + m.access.mockResolvedValue({ + credential: { ...installed, encryptedServiceAccountKey: 'x'.repeat(16_385) }, + }) + await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ + code: 'validation', + }) + expect(m.decrypt).not.toHaveBeenCalled() + }) + it('refuses mismatched installation identity', async () => { + m.parse.mockReturnValue({ installationId: 'evil', accountId: '7' }) + await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ + code: 'validation', + }) + expect(m.repository).not.toHaveBeenCalled() + }) + it('refuses repository replacement while allowing a rename of the same immutable repository', async () => { + await expect( + prepareGitHubInstallationSource({ ...input, previousConfig: { githubRepositoryId: '456' } }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + prepareGitHubInstallationSource({ + ...input, + previousConfig: { repository: 'old/name', githubRepositoryId: '123' }, + }) + ).resolves.toMatchObject({ githubRepositoryId: '123' }) + }) + it('keeps the marker when an edit omits it', async () => { + await expect( + prepareGitHubInstallationSource({ ...input, previousConfig: { githubRepositoryId: '123' } }) + ).resolves.toMatchObject({ githubRepositoryId: '123' }) + }) + it.each([null, { credential: { providerId: 'github-repositories' } }])( + 'cannot downgrade an existing installation by replacing or deleting its credential', + async (access) => { + m.access.mockResolvedValue(access) + await expect( + prepareGitHubInstallationSource({ ...input, previousConfig: { githubRepositoryId: '123' } }) + ).rejects.toMatchObject({ code: 'validation' }) + } + ) + it('leaves ordinary GitHub member setup available without an installation', async () => { + await expect( + prepareGitHubInstallationSource({ ...input, credentialId: undefined }) + ).resolves.toEqual(input.sourceConfig) + expect(m.repository).not.toHaveBeenCalled() + }) + it('does not accept the installation marker on ordinary member setup', async () => { + await expect( + prepareGitHubInstallationSource({ + ...input, + credentialId: undefined, + sourceConfig: { ...input.sourceConfig, githubRepositoryId: '123' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + it('propagates a provider denial without producing a source binding', async () => { + m.repository.mockRejectedValue(new Error('Repository access denied')) + await expect(prepareGitHubInstallationSource(input)).rejects.toThrow('Repository access denied') + }) +}) diff --git a/apps/sim/lib/knowledge/application/github-installation-source.ts b/apps/sim/lib/knowledge/application/github-installation-source.ts new file mode 100644 index 00000000000..c289b4accc4 --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installation-source.ts @@ -0,0 +1,84 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + parseGitHubInstallationBinding, + resolveGitHubInstallationRepository, +} from '@/lib/oauth/github-installation' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' + +interface GitHubInstallationSourceInput { + connectorType: string + credentialId?: string | null + organizationId?: string + isSearchIndex: boolean + accessMode: string + actingUserId: string + sourceConfig: Record + previousConfig?: Record +} + +/** Pins installation sources to a provider-verified repository identity before persisting them. */ +export async function prepareGitHubInstallationSource( + input: GitHubInstallationSourceInput +): Promise> { + const wasInstallation = input.previousConfig?.githubRepositoryId !== undefined + const assertedId = input.sourceConfig.githubRepositoryId + if (input.connectorType !== 'github') { + if (assertedId !== undefined) + throw new OrchestrationError( + 'validation', + 'githubRepositoryId is reserved for GitHub installation sources' + ) + return input.sourceConfig + } + const access = input.credentialId + ? await getCredentialActorContext(input.credentialId, input.actingUserId) + : null + const contentCredential = access?.credential + if (contentCredential?.providerId !== GITHUB_INSTALLATION_PROVIDER_ID) { + if (wasInstallation || assertedId !== undefined) + throw new OrchestrationError( + 'validation', + 'This source requires its GitHub installation. Create a new source to change the indexing method.' + ) + return input.sourceConfig + } + if (!input.organizationId || !input.isSearchIndex || input.accessMode !== 'members') + throw new OrchestrationError( + 'validation', + 'GitHub installations require organization Search with connected member access' + ) + if ( + !access || + !canUseCredential(access) || + contentCredential.organizationId !== input.organizationId || + contentCredential.workspaceId !== null || + contentCredential.type !== 'service_account' || + contentCredential.revokedAt || + !contentCredential.encryptedServiceAccountKey + ) + throw new OrchestrationError( + 'forbidden', + 'This GitHub installation is not available in this organization' + ) + if (contentCredential.encryptedServiceAccountKey.length > 16_384) + throw new OrchestrationError('validation', 'Reconnect this GitHub installation before using it') + const repository = input.sourceConfig.repository + if (typeof repository !== 'string' || !repository.trim()) + throw new OrchestrationError('validation', 'Choose a GitHub repository for this source') + const { decrypted } = await decryptSecret(contentCredential.encryptedServiceAccountKey) + const binding = parseGitHubInstallationBinding(JSON.parse(decrypted)) + if ( + binding.installationId !== contentCredential.providerSubjectId || + binding.accountId !== contentCredential.providerTenantId + ) + throw new OrchestrationError('validation', 'Reconnect this GitHub installation before using it') + const resolved = await resolveGitHubInstallationRepository(binding, repository.trim()) + if (wasInstallation && input.previousConfig?.githubRepositoryId !== resolved.id) + throw new OrchestrationError( + 'validation', + 'Create a new source to index a different GitHub repository' + ) + return { ...input.sourceConfig, repository: resolved.fullName, githubRepositoryId: resolved.id } +} diff --git a/apps/sim/lib/knowledge/application/github-installations.postgres.test.ts b/apps/sim/lib/knowledge/application/github-installations.postgres.test.ts new file mode 100644 index 00000000000..04e594b2d2a --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installations.postgres.test.ts @@ -0,0 +1,144 @@ +/** @vitest-environment node */ +import { db } from '@sim/db' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createEnterpriseSearchMigrationFixture } from '@/lib/knowledge/__integration__/migration-fixture' + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') + +const mocks = vi.hoisted(() => ({ token: vi.fn(), list: vi.fn() })) +vi.mock('@/lib/knowledge/application/authorized-knowledge-use-case', () => ({ + defineAuthorizedKnowledgeUseCase: (definition: { + execute: (args: { + principal: { userId: string } + input: { organizationId: string } + context: { organizationId: string } + }) => Promise + }) => ({ + execute: (args: { principal: { userId: string }; input: { organizationId: string } }) => + definition.execute({ ...args, context: { organizationId: args.input.organizationId } }), + }), +})) +vi.mock('@/lib/knowledge/application/operations', () => ({ + knowledgeOperations: { listGitHubInstallations: {}, connectGitHubInstallation: {} }, +})) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOrganizationContext: vi.fn(), +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireOrganizationSearchAvailable: vi.fn(), +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ resolveManagedOAuthToken: mocks.token })) +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => ({ + getPolicy: async () => ({ authorizationAppId: 'current-app', scopeVersion: 1 }), + }), +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + getGitHubInstallationConfiguration: () => ({ + configured: true, + installUrl: 'https://github.com/apps/example/installations/new', + }), + listUserAdminGitHubInstallations: mocks.list, + verifyGitHubInstallationBinding: vi.fn(), +})) +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: vi.fn() })) + +import { listGitHubSearchInstallations } from '@/lib/knowledge/application/github-installations' + +const { drizzle } = await import('drizzle-orm/postgres-js') +const schema = await import('@sim/db/schema') +const databaseUrl = process.env.KNOWLEDGE_ACL_TEST_DATABASE_URL + +/** Exercises the production reader-selection SQL against isolated local PostgreSQL tables. */ +describe.runIf(Boolean(databaseUrl))( + 'GitHub installation setup reader ownership in PostgreSQL', + () => { + let fixture: Awaited> + let executor: PostgresJsDatabase + + beforeAll(async () => { + fixture = await createEnterpriseSearchMigrationFixture(databaseUrl!) + await fixture.migrate() + await fixture.client.unsafe(` + ALTER TABLE credential ADD COLUMN revoked_at timestamp, + ADD COLUMN credential_group_option_id text, ADD COLUMN managed_oauth_scope_version integer; + ALTER TABLE credential_group ADD COLUMN options jsonb NOT NULL DEFAULT '[]'; + ALTER TABLE credential_group_enrollment ADD COLUMN user_id text; + INSERT INTO organization(id) VALUES ('setup-org'), ('other-org'); + `) + executor = drizzle(fixture.client, { schema }) + vi.mocked(db.select).mockImplementation(executor.select.bind(executor)) + }) + + afterAll(async () => { + await fixture?.cleanup() + }) + + beforeEach(async () => { + vi.clearAllMocks() + mocks.token.mockResolvedValue({ accessToken: 'ghu_alice' }) + mocks.list.mockResolvedValue([]) + await fixture.client.unsafe(` + TRUNCATE credential, credential_group_enrollment, credential_group CASCADE; + INSERT INTO credential_group(id,organization_id,name,status,options) + VALUES ('setup-group','setup-org','GitHub','active', + '[{"id":"github-option","provider":"github-repositories","status":"active"}]'); + INSERT INTO credential_group_enrollment(id,credential_group_id,email,status,user_id) + VALUES ('setup-enrollment','setup-group','alice@example.com','completed','alice'); + INSERT INTO credential(id,organization_id,type,provider_id,provider_subject_id,authorization_app_id, + managed_oauth_status,managed_oauth_scope_version,granted_scopes,encrypted_oauth_token_set,granted_at, + credential_group_enrollment_id,credential_group_option_id) + VALUES ('alice-github','setup-org','managed_oauth','github-repositories','123','current-app','active',1, + ARRAY[]::text[],'encrypted',now(),'setup-enrollment','github-option'); + `) + }) + + function list(userId = 'alice', organizationId = 'setup-org') { + return listGitHubSearchInstallations.execute({ + principal: { kind: 'session', userId, sessionId: 'session' }, + input: { organizationId }, + }) + } + + it('selects the acting person’s active credential in the canonical organization', async () => { + expect(await list()).toMatchObject({ needsUserConnection: false }) + expect(mocks.token).toHaveBeenCalledWith({ + credentialId: 'alice-github', + organizationId: 'setup-org', + expectedProviderId: 'github-repositories', + requiredScopes: [], + }) + expect(await list('bob')).toMatchObject({ needsUserConnection: true }) + expect(await list('alice', 'other-org')).toMatchObject({ needsUserConnection: true }) + expect(mocks.token).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['credential owner', "UPDATE credential SET organization_id='other-org'"], + ['group owner', "UPDATE credential_group SET organization_id='other-org'"], + ['credential revocation', 'UPDATE credential SET revoked_at=now()'], + ['enrollment revocation', 'UPDATE credential_group_enrollment SET revoked_at=now()'], + ['revoked enrollment status', "UPDATE credential_group_enrollment SET status='revoked'"], + ['inactive credential', "UPDATE credential SET managed_oauth_status='revoked'"], + ['inactive group', "UPDATE credential_group SET status='archived'"], + [ + 'disabled option', + `UPDATE credential_group SET options='[{"id":"github-option","provider":"github-repositories","status":"disabled"}]'`, + ], + [ + 'different option provider', + `UPDATE credential_group SET options='[{"id":"github-option","provider":"other","status":"active"}]'`, + ], + ['different option', "UPDATE credential SET credential_group_option_id='other-option'"], + ['stale app identity', "UPDATE credential SET authorization_app_id='old-app'"], + ['stale scope policy', 'UPDATE credential SET managed_oauth_scope_version=0'], + ])('denies %s before using any GitHub token', async (_name, mutation) => { + await fixture.client.unsafe(mutation) + expect(await list()).toMatchObject({ needsUserConnection: true, installations: [] }) + expect(mocks.token).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() + }) + } +) diff --git a/apps/sim/lib/knowledge/application/github-installations.test.ts b/apps/sim/lib/knowledge/application/github-installations.test.ts new file mode 100644 index 00000000000..645180d446e --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installations.test.ts @@ -0,0 +1,208 @@ +/** @vitest-environment node */ +import { db } from '@sim/db' +import { credential, credentialGroup, member } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const m = vi.hoisted(() => ({ + configuration: vi.fn(), + list: vi.fn(), + verify: vi.fn(), + token: vi.fn(), + encrypt: vi.fn(), + audit: vi.fn(), +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + CREDENTIAL_CREATED: 'credential.created', + CREDENTIAL_UPDATED: 'credential.updated', + }, + AuditResourceType: { CREDENTIAL: 'credential' }, + recordAudit: m.audit, +})) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeOrganizationContext: async ({ organizationId }: { organizationId: string }) => ({ + organizationId, + workspaceId: undefined, + }), +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + requireOrganizationSearchAvailable: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: async () => null, +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + getGitHubInstallationConfiguration: m.configuration, + listUserAdminGitHubInstallations: m.list, + verifyGitHubInstallationBinding: m.verify, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ resolveManagedOAuthToken: m.token })) +vi.mock('@/lib/credential-groups/provider-registry', () => ({ + getCredentialGroupProviderAdapter: () => ({ + getPolicy: async () => ({ authorizationAppId: 'current-app', scopeVersion: 1 }), + }), +})) +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: m.encrypt })) + +import { + connectGitHubSearchInstallation, + listGitHubSearchInstallations, +} from '@/lib/knowledge/application/github-installations' + +const principal = { kind: 'session', userId: 'admin', sessionId: 'session' } as const +const input = { organizationId: 'org', installationId: '42' } +const reader = { id: 'reader', authorizationAppId: 'current-app', groupId: 'group', subjectId: '9' } +const binding = { + type: 'github_app_installation', + version: 1, + appId: '1', + appClientId: 'client', + installationId: '42', + accountId: '7', + accountLogin: 'example', + accountType: 'Organization', + repositorySelection: 'selected', +} +const connect = () => connectGitHubSearchInstallation.execute({ principal, input }) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + m.configuration.mockReturnValue({ + configured: true, + installUrl: 'https://github.com/apps/example/installations/new', + }) + m.list.mockResolvedValue([binding]) + m.token.mockResolvedValue({ accessToken: 'ghu_reader' }) + m.verify.mockResolvedValue(binding) + m.encrypt.mockResolvedValue({ encrypted: 'encrypted-installation-binding' }) +}) +afterAll(resetDbChainMock) + +function setupReader() { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [reader]) +} +function setupTransaction(currentReader = reader, existing: { id: string }[] = []) { + queueTableRows(member, [{ id: 'admin-membership' }]) + queueTableRows(credentialGroup, [{ id: 'group' }]) + queueTableRows(credential, [currentReader]) + queueTableRows(credential, existing) +} + +describe('GitHub Search installation application operations', () => { + it.each(['member', undefined])( + 'requires a current Sim organization admin before provider access: %s', + async (role) => { + queueTableRows(member, role ? [{ role }] : []) + await expect(connect()).rejects.toMatchObject({ code: role ? 'forbidden' : 'not_found' }) + expect(m.token).not.toHaveBeenCalled() + expect(m.verify).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + } + ) + it('refuses API keys for installation setup', async () => { + await expect( + connectGitHubSearchInstallation.execute({ + principal: { kind: 'personal_api_key', userId: 'admin', keyId: 'key' }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(m.verify).not.toHaveBeenCalled() + }) + it('reports unavailable configuration without exposing installations', async () => { + queueTableRows(member, [{ role: 'admin' }]) + m.configuration.mockReturnValue({ configured: false, installUrl: null }) + await expect(listGitHubSearchInstallations.execute({ principal, input })).resolves.toEqual({ + available: false, + installUrl: null, + needsUserConnection: false, + installations: [], + }) + expect(m.list).not.toHaveBeenCalled() + }) + it('asks for the current admin’s own connection when none is available', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, []) + await expect( + listGitHubSearchInstallations.execute({ principal, input }) + ).resolves.toMatchObject({ needsUserConnection: true, installations: [] }) + expect(m.token).not.toHaveBeenCalled() + }) + it('uses only the organization-bound managed reader token to list installations', async () => { + setupReader() + const signal = new AbortController().signal + await listGitHubSearchInstallations.execute({ principal, input: { ...input, signal } }) + expect(m.token).toHaveBeenCalledWith({ + credentialId: 'reader', + organizationId: 'org', + expectedProviderId: 'github-repositories', + requiredScopes: [], + }) + expect(m.list).toHaveBeenCalledWith('ghu_reader', { signal }) + }) + it('reverifies GitHub admin authority before persisting an installation', async () => { + setupReader() + m.verify.mockRejectedValue(new Error('GitHub administrator access required')) + await expect(connect()).rejects.toThrow('GitHub administrator access required') + expect(m.encrypt).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + }) + it('persists an encrypted installation binding and grants management to the actual admin', async () => { + setupReader() + setupTransaction() + const result = await connect() + expect(result).toMatchObject({ + created: true, + credential: { displayName: 'GitHub App · example' }, + }) + expect(m.verify).toHaveBeenCalledWith('ghu_reader', '42', { signal: undefined }) + expect(m.encrypt).toHaveBeenCalledWith(JSON.stringify(binding)) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: 'org', + workspaceId: null, + providerId: 'github-app-installation', + type: 'service_account', + createdBy: 'admin', + encryptedServiceAccountKey: 'encrypted-installation-binding', + }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: result.credential.id, + userId: 'admin', + role: 'admin', + status: 'active', + }) + ) + expect(m.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'admin', + resourceId: result.credential.id, + action: 'credential.created', + }) + ) + }) + it('reuses the same installation credential on repeat setup', async () => { + setupReader() + setupTransaction(reader, [{ id: 'existing' }]) + await expect(connect()).resolves.toMatchObject({ + created: false, + credential: { id: 'existing' }, + }) + }) + it('refuses if Sim administrator access was removed during GitHub verification', async () => { + setupReader() + queueTableRows(member, []) + await expect(connect()).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) + it('refuses if the reader identity changed during GitHub verification', async () => { + setupReader() + setupTransaction({ ...reader, subjectId: 'different-person' }) + await expect(connect()).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.values).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/github-installations.ts b/apps/sim/lib/knowledge/application/github-installations.ts new file mode 100644 index 00000000000..917e9569cfb --- /dev/null +++ b/apps/sim/lib/knowledge/application/github-installations.ts @@ -0,0 +1,242 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + credentialMember, + member, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { encryptSecret } from '@/lib/core/security/encryption' +import { LIVE_ENROLLMENT_STATUSES } from '@/lib/credential-groups/credentials' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { resolveManagedOAuthToken } from '@/lib/credentials/managed-oauth' +import type { DbOrTx } from '@/lib/db/types' +import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + getGitHubInstallationConfiguration, + listUserAdminGitHubInstallations, + verifyGitHubInstallationBinding, +} from '@/lib/oauth/github-installation' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' + +interface InstallationInput { + organizationId: string + signal?: AbortSignal +} + +interface ConnectInstallationInput extends InstallationInput { + installationId: string +} + +/** Selects only the acting person's live, organization-bound GitHub connection. */ +async function findReaderCredential(executor: DbOrTx, organizationId: string, userId: string) { + const policy = await getCredentialGroupProviderAdapter('github-repositories').getPolicy( + undefined, + { organizationId } + ) + const rows = await executor + .select({ + id: credential.id, + authorizationAppId: credential.authorizationAppId, + groupId: credentialGroup.id, + subjectId: credential.providerSubjectId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + eq(credential.organizationId, organizationId), + eq(credentialGroup.organizationId, organizationId), + eq(credentialGroupEnrollment.userId, userId), + eq(credential.type, 'managed_oauth'), + eq(credential.providerId, 'github-repositories'), + eq(credential.managedOauthStatus, 'active'), + eq(credential.authorizationAppId, policy.authorizationAppId), + eq(credential.managedOauthScopeVersion, policy.scopeVersion), + isNull(credential.revokedAt), + eq(credentialGroup.status, 'active'), + inArray(credentialGroupEnrollment.status, [...LIVE_ENROLLMENT_STATUSES]), + isNull(credentialGroupEnrollment.revokedAt), + sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'id' = ${credential.credentialGroupOptionId} + AND option->>'provider' = 'github-repositories' AND option->>'status' = 'active')` + ) + ) + .limit(2) + if (rows.length > 1) + throw new OrchestrationError( + 'conflict', + 'Connect one GitHub account for this organization before choosing an installation' + ) + return rows[0] ?? null +} + +async function readerToken(organizationId: string, credentialId: string) { + return resolveManagedOAuthToken({ + credentialId, + organizationId, + expectedProviderId: 'github-repositories', + requiredScopes: [], + }) +} + +export const listGitHubSearchInstallations = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.listGitHubInstallations, + resolveContext: ({ input }: { input: InstallationInput }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input, context }) { + await requireOrganizationSearchAvailable(context.organizationId) + const configuration = getGitHubInstallationConfiguration() + const reader = configuration.configured + ? await findReaderCredential(db, context.organizationId, principal.userId) + : null + const installations = reader + ? await listUserAdminGitHubInstallations( + (await readerToken(context.organizationId, reader.id)).accessToken, + { signal: input.signal } + ) + : [] + return { + available: configuration.configured, + installUrl: configuration.installUrl, + needsUserConnection: configuration.configured && !reader, + installations, + } + }, +}) + +export const connectGitHubSearchInstallation = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.connectGitHubInstallation, + resolveContext: ({ input }: { input: ConnectInstallationInput }) => + resolveKnowledgeOrganizationContext(input), + async execute({ principal, input, context }) { + await requireOrganizationSearchAvailable(context.organizationId) + if (!getGitHubInstallationConfiguration().configured) + throw new OrchestrationError( + 'validation', + 'GitHub App installation indexing is not configured for this environment' + ) + const reader = await findReaderCredential(db, context.organizationId, principal.userId) + if (!reader) + throw new OrchestrationError( + 'validation', + 'Connect your GitHub account before choosing an installation' + ) + const { accessToken } = await readerToken(context.organizationId, reader.id) + const binding = await verifyGitHubInstallationBinding(accessToken, input.installationId, { + signal: input.signal, + }) + const { encrypted } = await encryptSecret(JSON.stringify(binding)) + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`github-search:${context.organizationId}:${binding.installationId}`}, 0))` + ) + const [admin] = await tx + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.organizationId, context.organizationId), + eq(member.userId, principal.userId), + inArray(member.role, ['admin', 'owner']) + ) + ) + .for('update') + .limit(1) + if (!admin) + throw new OrchestrationError('forbidden', 'Organization administrator access is required') + /** Lock the group before rechecking enrollment, as account configuration and revocation do. */ + await tx + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.id, reader.groupId), + eq(credentialGroup.organizationId, context.organizationId) + ) + ) + .for('update') + .limit(1) + const current = await findReaderCredential(tx, context.organizationId, principal.userId) + if ( + current?.id !== reader.id || + current.authorizationAppId !== reader.authorizationAppId || + current.subjectId !== reader.subjectId + ) + throw new OrchestrationError( + 'conflict', + 'Your GitHub connection changed during setup. Try again.' + ) + const [existing] = await tx + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.organizationId, context.organizationId), + eq(credential.type, 'service_account'), + eq(credential.providerId, GITHUB_INSTALLATION_PROVIDER_ID), + eq(credential.providerSubjectId, binding.installationId), + eq(credential.authorizationAppId, reader.authorizationAppId!) + ) + ) + .for('update') + .limit(1) + const id = existing?.id ?? generateId() + const now = new Date() + const displayName = `GitHub App · ${binding.accountLogin}` + const values = { + displayName, + encryptedServiceAccountKey: encrypted, + providerTenantId: binding.accountId, + revokedAt: null, + updatedAt: now, + } + if (existing) await tx.update(credential).set(values).where(eq(credential.id, id)) + else + await tx.insert(credential).values({ + id, + organizationId: context.organizationId, + workspaceId: null, + type: 'service_account', + providerId: GITHUB_INSTALLATION_PROVIDER_ID, + providerSubjectId: binding.installationId, + authorizationAppId: reader.authorizationAppId, + createdBy: principal.userId, + ...values, + }) + await tx + .insert(credentialMember) + .values({ + id: generateId(), + credentialId: id, + userId: principal.userId, + role: 'admin', + status: 'active', + joinedAt: now, + }) + .onConflictDoUpdate({ + target: [credentialMember.credentialId, credentialMember.userId], + set: { role: 'admin', status: 'active', joinedAt: now, updatedAt: now }, + }) + return { credential: { id, displayName }, created: !existing } + }) + }, + projectAudit: ({ result }) => ({ + action: result.created ? AuditAction.CREDENTIAL_CREATED : AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: 'Connected a GitHub App installation for Search indexing', + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index d5732c64c53..246bcd4e307 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -16,6 +16,8 @@ describe('knowledge operation registry', () => { it('defines unique stable semantic operation IDs', () => { const ids = Object.values(knowledgeOperations).map((operation) => operation.id) expect(ids).toEqual([ + 'knowledge.github.installations.list', + 'knowledge.github.installations.connect', 'knowledge.slack.prepare', 'knowledge.slack.oauth.start', 'knowledge.slack.oauth.complete', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index b72dadcf115..0e7ae360969 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -102,6 +102,24 @@ const HUMAN_COPILOT_AND_EXECUTOR_PRINCIPAL_POLICY = { } as const export const knowledgeOperations = { + listGitHubInstallations: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.installations.list', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), + connectGitHubInstallation: defineKnowledgeOperation( + defineWorkspaceOperation({ + id: 'knowledge.github.installations.connect', + minimumRole: 'admin', + workspaceApiKey: 'deny', + capability: 'knowledge.use', + principalKinds: ['session'], + }) + ), prepareSlackInstallation: defineKnowledgeOperation( defineWorkspaceOperation({ id: 'knowledge.slack.prepare', diff --git a/apps/sim/lib/knowledge/application/read-indexed-document.ts b/apps/sim/lib/knowledge/application/read-indexed-document.ts index f611d2f19aa..37acf6579b2 100644 --- a/apps/sim/lib/knowledge/application/read-indexed-document.ts +++ b/apps/sim/lib/knowledge/application/read-indexed-document.ts @@ -14,6 +14,7 @@ import { import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import type { ChunkQueryResult } from '@/lib/knowledge/chunks/types' +import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/citation' import { findSearchIndex } from '@/lib/knowledge/search/search-index' import { @@ -48,14 +49,14 @@ export interface ReadIndexedKnowledgeDocumentResult { pagination?: ChunkQueryResult['pagination'] } -function activeDocumentConditions(knowledgeBaseId: string, access: KnowledgeAccessScope) { +function activeDocumentConditions(knowledgeBaseId: string, access?: KnowledgeAccessScope) { return [ eq(document.knowledgeBaseId, knowledgeBaseId), eq(document.enabled, true), eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - knowledgeAccessCondition(access), + access ? knowledgeAccessCondition(access) : undefined, ] } @@ -112,21 +113,29 @@ export const readIndexedKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ { knowledgeBaseId, ...assertions }, principal ) - const access = await knowledgeContext.access.get() let documentId: string if (input.target.kind === 'id') { documentId = input.target.documentId } else { - const matches = await db - .select({ id: document.id }) - .from(document) - .where( - and( - ...activeDocumentConditions(knowledgeBaseId, access), - eq(document.sourceUrl, input.target.url.trim()) - ) + const conditions = [ + ...activeDocumentConditions(knowledgeBaseId), + eq(document.sourceUrl, input.target.url.trim()), + ] + const matches: { id: string }[] = [] + for await (const accessCondition of knowledgeReadAccessBatches( + knowledgeContext.access, + conditions, + input.signal + )) { + matches.push( + ...(await db + .select({ id: document.id }) + .from(document) + .where(and(...conditions, accessCondition)) + .limit(2 - matches.length)) ) - .limit(2) + if (matches.length > 1) break + } if (!matches.length) throw new OrchestrationError('not_found', 'Document not found') if (matches.length > 1) { throw new OrchestrationError( @@ -136,6 +145,7 @@ export const readIndexedKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ } documentId = matches[0].id } + const access = await knowledgeContext.access.getForDocuments([documentId], input.signal) input.signal?.throwIfAborted() const { document: doc } = await readKnowledgeDocument.execute({ principal, diff --git a/apps/sim/lib/knowledge/application/search-source-overview.ts b/apps/sim/lib/knowledge/application/search-source-overview.ts index fbf1b56f503..b75d5a1611d 100644 --- a/apps/sim/lib/knowledge/application/search-source-overview.ts +++ b/apps/sim/lib/knowledge/application/search-source-overview.ts @@ -5,12 +5,12 @@ import type { SearchSourceOverview } from '@/lib/api/contracts/knowledge/connect import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants' +import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integration-policy' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -19,10 +19,8 @@ export const readSearchSourceOverview = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.readSearchSourceOverview, resolveContext: ({ input }: { input: ResourceOwner }) => resolveKnowledgeOwnerContext(input), async execute({ principal, context }): Promise { - const [availability, access] = await Promise.all([ - resolveKnowledgeAccessAvailability(context), - createKnowledgeAccessProvider(principal, context).get(), - ]) + const availability = await resolveKnowledgeAccessAvailability(context) + const access = createKnowledgeAccessProvider(principal, context) const providerTypes = Object.keys(CONNECTOR_META_REGISTRY) if (providerTypes.length > MAX_SEARCH_SOURCE_PROVIDER_TYPES) { throw new Error('Search provider catalog exceeds the overview bound') @@ -60,80 +58,93 @@ export const readSearchSourceOverview = defineAuthorizedKnowledgeUseCase({ notInArray(knowledgeConnector.memberSyncStatus, ['disabled']) ) ) - const readableDocument = and( + const documentConditions = and( eq(document.connectorId, knowledgeConnector.id), eq(document.knowledgeBaseId, knowledgeConnector.knowledgeBaseId), eq(document.enabled, true), eq(document.userExcluded, false), isNull(document.archivedAt), - isNull(document.deletedAt), - knowledgeAccessCondition(access) + isNull(document.deletedAt) ) const providersQuery = () => db .selectDistinct({ connectorType: knowledgeConnector.connectorType }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) - const [providers, indexing, searchable] = await Promise.all([ - providersQuery().where(configured).limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES), - availability.memberScoped || availability.sourceMirrored - ? providersQuery() - .where( - and( - configured, - syncingEnabled, - or( - inArray(knowledgeConnector.status, ['pending', 'syncing']), - and( - eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.memberSyncStatus, ['pending', 'running']) - ), + const providers = await providersQuery() + .where(configured) + .limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES) + const indexingTypes = new Set() + let hasSearchableDocuments = false + for await (const accessCondition of knowledgeReadAccessBatches(access, [ + configured, + available, + documentConditions, + ])) { + const readableDocument = and(documentConditions, accessCondition) + const [indexing, searchable] = await Promise.all([ + availability.memberScoped || availability.sourceMirrored + ? providersQuery() + .where( + and( + configured, + syncingEnabled, + or( + inArray(knowledgeConnector.status, ['pending', 'syncing']), + and( + eq(knowledgeConnector.accessMode, 'members'), + inArray(knowledgeConnector.memberSyncStatus, ['pending', 'running']) + ), + exists( + db + .select({ id: document.id }) + .from(document) + .where( + and( + readableDocument, + inArray(document.processingStatus, ['pending', 'processing']) + ) + ) + ) + ) + ) + ) + .limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES) + : [], + availability.memberScoped || availability.sourceMirrored + ? db + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId)) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) + .where( + and( + configured, + available, + readableDocument, + eq(document.processingStatus, 'completed'), exists( db - .select({ id: document.id }) - .from(document) + .select({ id: embedding.id }) + .from(embedding) .where( - and( - readableDocument, - inArray(document.processingStatus, ['pending', 'processing']) - ) + and(eq(embedding.documentId, document.id), eq(embedding.enabled, true)) ) ) ) ) - ) - .limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES) - : [], - availability.memberScoped || availability.sourceMirrored - ? db - .select({ id: document.id }) - .from(document) - .innerJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId)) - .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) - .where( - and( - configured, - available, - readableDocument, - eq(document.processingStatus, 'completed'), - exists( - db - .select({ id: embedding.id }) - .from(embedding) - .where(and(eq(embedding.documentId, document.id), eq(embedding.enabled, true))) - ) - ) - ) - .limit(1) - : [], - ]) - const indexingTypes = new Set(indexing.map((provider) => provider.connectorType)) + .limit(1) + : [], + ]) + for (const provider of indexing) indexingTypes.add(provider.connectorType) + hasSearchableDocuments ||= searchable.length > 0 + } return { providers: providers.map(({ connectorType }) => ({ connectorType, isSyncing: indexingTypes.has(connectorType), })), - hasSearchableDocuments: searchable.length > 0, + hasSearchableDocuments, } }, }) diff --git a/apps/sim/lib/knowledge/application/search-source-progress.ts b/apps/sim/lib/knowledge/application/search-source-progress.ts index dbabc0f8b00..5543c3ec974 100644 --- a/apps/sim/lib/knowledge/application/search-source-progress.ts +++ b/apps/sim/lib/knowledge/application/search-source-progress.ts @@ -31,7 +31,9 @@ export const readSearchSourceProgress = defineAuthorizedKnowledgeUseCase({ `Provide between 1 and ${MAX_SEARCH_SOURCE_PROGRESS_ITEMS} sources` ) } - const access = await createKnowledgeAccessProvider(principal, context).get() + const access = await createKnowledgeAccessProvider(principal, context).getForConnectors( + input.connectorIds + ) const hasDocumentsInState = (statuses: string[]) => sql`${exists( db diff --git a/apps/sim/lib/knowledge/application/search-sources.test.ts b/apps/sim/lib/knowledge/application/search-sources.test.ts index 036aed5ce73..7780bf13f9b 100644 --- a/apps/sim/lib/knowledge/application/search-sources.test.ts +++ b/apps/sim/lib/knowledge/application/search-sources.test.ts @@ -41,6 +41,7 @@ vi.mock('@/lib/knowledge/access/scope', () => ({ })) vi.mock('@/lib/knowledge/access/predicate', () => ({ knowledgeAccessCondition: mocks.predicate, + knowledgeMetadataCandidateAccessCondition: mocks.predicate, })) vi.mock('@/connectors/registry', () => { const registry = { @@ -113,7 +114,11 @@ beforeEach(() => { mocks.permission.mockResolvedValue('read') mocks.availability.mockResolvedValue({ sourceMirrored: true, memberScoped: true }) mocks.memberships.mockResolvedValue(new Map()) - mocks.access.mockReturnValue({ get: async () => access }) + mocks.access.mockReturnValue({ + get: async () => access, + getForConnectors: async () => access, + getForDocuments: async () => access, + }) mocks.predicate.mockReturnValue(ACL) }) diff --git a/apps/sim/lib/knowledge/application/search-sources.ts b/apps/sim/lib/knowledge/application/search-sources.ts index 8e1599057be..a639708d49d 100644 --- a/apps/sim/lib/knowledge/application/search-sources.ts +++ b/apps/sim/lib/knowledge/application/search-sources.ts @@ -106,7 +106,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ if (candidates.length === 0) return { sources: [], nextCursor: null } const scanned = candidates.slice(0, SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) - const [availability, memberships, viewers, access, approvals] = await Promise.all([ + const [availability, memberships, viewers, approvals] = await Promise.all([ resolveKnowledgeAccessAvailability(context), resolveViewerConnectorMemberships({ userId: principal.userId, @@ -119,7 +119,6 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ .from(user) .where(eq(user.id, principal.userId)) .limit(1), - createKnowledgeAccessProvider(principal, context).get(), context.organizationId ? listOrganizationSearchApprovals(context.organizationId) : null, ]) /** Filtering uses the same safe display labels and verified membership as the source rows. */ @@ -145,6 +144,9 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ ).toString('base64url') : null if (rows.length === 0) return { sources: [], nextCursor } + const access = await createKnowledgeAccessProvider(principal, context).getForConnectors( + rows.map((row) => row.id) + ) const documentStates = await db .select({ connectorId: document.connectorId, diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index ce1a911aed1..185e4d64760 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -626,7 +626,12 @@ describe('knowledge search application use case', () => { }, }) - expect(mocks.getDocumentMetadata).toHaveBeenCalledWith(['document-1'], expect.anything()) + expect(mocks.getDocumentMetadata).toHaveBeenCalledWith( + ['document-1'], + expect.anything(), + undefined, + undefined + ) expect(result.results[0]).toMatchObject({ documentName: 'guide.pdf', sourceUrl: 'https://example.com/guide', diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 946688eb782..a47c6f7141c 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -229,7 +229,11 @@ async function resolveKnowledgeSearchContext( return { ...context, knowledgeBases: knowledgeBases as ActiveKnowledgeBaseReference[], - access: createKnowledgeAccessProvider(principal, context), + access: createKnowledgeAccessProvider(principal, { + ...context, + knowledgeBaseIds: knowledgeBases.map((base) => base!.id), + signal: input.signal, + }), } } if (!canonicalWorkspaceId) { @@ -241,7 +245,11 @@ async function resolveKnowledgeSearchContext( return { ...workspaceContext, knowledgeBases: knowledgeBases as ActiveKnowledgeBaseReference[], - access: createKnowledgeAccessProvider(principal, { workspaceId: canonicalWorkspaceId }), + access: createKnowledgeAccessProvider(principal, { + workspaceId: canonicalWorkspaceId, + knowledgeBaseIds: knowledgeBases.map((base) => base!.id), + signal: input.signal, + }), } } @@ -366,6 +374,8 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ topK: candidateTopK, filters: input.filters, access, + accessProvider: context.organizationId ? context.access : undefined, + signal: input.signal, searchMode: searchDefaults.searchMode, boostRecency: searchDefaults.boostRecency, query: input.query, @@ -567,7 +577,9 @@ export const searchKnowledge = defineAuthorizedKnowledgeUseCase({ */ const basicDocumentMetadata = await getDocumentMetadataByIds( rows.map((row) => row.documentId), - access + access, + context.organizationId ? context.access : undefined, + input.signal ) const results = rows .filter((row) => basicDocumentMetadata[row.documentId]) diff --git a/apps/sim/lib/knowledge/application/slack-search/source-status.ts b/apps/sim/lib/knowledge/application/slack-search/source-status.ts index 817299c61bc..d9a5e764f39 100644 --- a/apps/sim/lib/knowledge/application/slack-search/source-status.ts +++ b/apps/sim/lib/knowledge/application/slack-search/source-status.ts @@ -4,8 +4,8 @@ import { and, eq, exists, isNull } from 'drizzle-orm' import type { OperationUseCase } from '@/lib/core/application/operation' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' +import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' const operation = defineOrganizationOperation({ id: 'knowledge.slack.sources.status', @@ -25,31 +25,32 @@ export const getSlackSearchSourceStatus: OperationUseCase< operation, async execute({ principal, input }) { await authorizeOrganizationOperation(principal, operation, input) - const access = await createKnowledgeAccessProvider(principal, input).get() - const [visible] = await db - .select({ id: document.id }) - .from(document) - .innerJoin(knowledgeBase, eq(knowledgeBase.id, document.knowledgeBaseId)) - .where( - and( - eq(knowledgeBase.organizationId, input.organizationId), - eq(knowledgeBase.isSearchIndex, true), - isNull(knowledgeBase.deletedAt), - eq(document.processingStatus, 'completed'), - eq(document.enabled, true), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - knowledgeAccessCondition(access), - exists( - db - .select({ id: embedding.id }) - .from(embedding) - .where(and(eq(embedding.documentId, document.id), eq(embedding.enabled, true))) - ) - ) - ) - .limit(1) - return { hasSearchableDocuments: Boolean(visible) } + const access = createKnowledgeAccessProvider(principal, input) + const conditions = [ + eq(knowledgeBase.organizationId, input.organizationId), + eq(knowledgeBase.isSearchIndex, true), + isNull(knowledgeBase.deletedAt), + eq(document.processingStatus, 'completed'), + eq(document.enabled, true), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + exists( + db + .select({ id: embedding.id }) + .from(embedding) + .where(and(eq(embedding.documentId, document.id), eq(embedding.enabled, true))) + ), + ] + for await (const accessCondition of knowledgeReadAccessBatches(access, conditions)) { + const [visible] = await db + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, document.knowledgeBaseId)) + .where(and(...conditions, accessCondition)) + .limit(1) + if (visible) return { hasSearchableDocuments: true } + } + return { hasSearchableDocuments: false } }, } diff --git a/apps/sim/lib/knowledge/application/tags.ts b/apps/sim/lib/knowledge/application/tags.ts index c2e836f1075..dd8b9a56c8f 100644 --- a/apps/sim/lib/knowledge/application/tags.ts +++ b/apps/sim/lib/knowledge/application/tags.ts @@ -357,7 +357,7 @@ export const readKnowledgeTagUsage = defineAuthorizedKnowledgeUseCase({ return { usage: await getTagUsageStats( context.knowledgeBaseId, - await context.access.get(), + context.organizationId ? context.access : await context.access.get(), generateRequestId() ), } @@ -373,7 +373,7 @@ export const readDetailedKnowledgeTagUsage = defineAuthorizedKnowledgeUseCase({ usage: await getTagUsage( context.knowledgeBaseId, generateRequestId(), - await context.access.get() + context.organizationId ? context.access : await context.access.get() ), } }, diff --git a/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts b/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts index 71ece9c4b13..ee1ccd8e2cf 100644 --- a/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts +++ b/apps/sim/lib/knowledge/chunks/keyset-sql.test.ts @@ -108,7 +108,7 @@ describe('chunk list generated SQL', () => { expect(where.sql).toContain('"document"."acl" && ARRAY[$2, $3]::text[]') expect(where.sql).toContain('required_clause.tokens ?| ARRAY[$4, $5]::text[]') expect(where.sql).toContain( - '"knowledge_connector_member"."subject_token" = ANY(ARRAY[$7, $8]::text[])' + '"knowledge_connector_member"."subject_token" = ANY(ARRAY[$8, $9]::text[])' ) expect(where.params).toEqual([ 'document-1', @@ -116,6 +116,7 @@ describe('chunk list generated SQL', () => { 'ws', 'pub', 'ws', + 'github-app-installation', SOURCE_ACL_MAX_AGE_MS, 'pub', 'ws', diff --git a/apps/sim/lib/knowledge/connectors/access-token.test.ts b/apps/sim/lib/knowledge/connectors/access-token.test.ts index 8e0d59c34dc..4da693c4d03 100644 --- a/apps/sim/lib/knowledge/connectors/access-token.test.ts +++ b/apps/sim/lib/knowledge/connectors/access-token.test.ts @@ -100,6 +100,24 @@ describe('resolveConnectorAccessToken', () => { expect(mockDecryptApiKey).not.toHaveBeenCalled() }) + it('passes the immutable repository scope to installation token resolution', async () => { + await resolveConnectorAccessToken({ + auth: { mode: 'oauth', provider: 'github-repositories' }, + connector: credentialConnector('installation-credential'), + userId: 'actor', + requestId: 'request', + sourceConfig: { repository: 'team/repo', githubRepositoryId: '101' }, + }) + expect(mockResolveTokenBundle).toHaveBeenCalledWith( + 'installation-credential', + 'actor', + 'request', + undefined, + undefined, + { githubRepositoryScope: { repository: 'team/repo', repositoryId: '101' } } + ) + }) + it('does not accept an undeclared key alternative on other OAuth connectors', async () => { await expect( resolveConnectorAccessToken({ diff --git a/apps/sim/lib/knowledge/connectors/access-token.ts b/apps/sim/lib/knowledge/connectors/access-token.ts index 2a46a10d323..8fc29e5b98b 100644 --- a/apps/sim/lib/knowledge/connectors/access-token.ts +++ b/apps/sim/lib/knowledge/connectors/access-token.ts @@ -101,12 +101,26 @@ export async function resolveConnectorAccessToken(params: { } const subject = connectorServiceAccountSubject(auth, params.sourceConfig) + const githubRepositoryScope = + auth.mode === 'oauth' && auth.provider === 'github-repositories' + ? { + repositoryId: + typeof params.sourceConfig.githubRepositoryId === 'string' + ? params.sourceConfig.githubRepositoryId + : undefined, + repository: + typeof params.sourceConfig.repository === 'string' + ? params.sourceConfig.repository + : undefined, + } + : undefined const bundle = await resolveCredentialTokenBundle( connector.credentialId, userId, requestId, connectorServiceAccountScopes(auth), - subject + subject, + ...(githubRepositoryScope ? [{ githubRepositoryScope }] : []) ) if (!bundle?.accessToken) return null diff --git a/apps/sim/lib/knowledge/documents/service.test.ts b/apps/sim/lib/knowledge/documents/service.test.ts new file mode 100644 index 00000000000..974a5e3fdd1 --- /dev/null +++ b/apps/sim/lib/knowledge/documents/service.test.ts @@ -0,0 +1,54 @@ +/** @vitest-environment node */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKSPACE_ACCESS_SCOPE } from '@/lib/knowledge/access/scope' +import type { KnowledgeAccessProvider, KnowledgeAccessScope } from '@/lib/knowledge/access/types' +import { getDocuments } from '@/lib/knowledge/documents/service' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) + +describe('getDocuments pagination', () => { + it('keeps static scopes on the direct count and offset queries', async () => { + queueTableRows(document, [{ count: 25 }]) + queueTableRows(document, []) + + const result = await getDocuments( + 'knowledge-1', + { limit: 5, offset: 20 }, + 'request-1', + WORKSPACE_ACCESS_SCOPE + ) + + expect(result.pagination).toEqual({ total: 25, limit: 5, offset: 20, hasMore: false }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.selectDistinct).not.toHaveBeenCalled() + expect(dbChainMockFns.as).not.toHaveBeenCalled() + expect(dbChainMockFns.offset).toHaveBeenCalledWith(20) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(5) + }) + + it('reads a newly admitted candidate even when the earlier count admitted no documents', async () => { + const identity: KnowledgeAccessScope = { kind: 'user', userId: 'reader', tokens: [] } + const resolve = vi.fn(async () => identity) + const access: KnowledgeAccessProvider = { + get: async () => identity, + getForConnectors: async () => identity, + getForDocuments: resolve, + } + queueTableRows(document, [{ count: 0 }]) + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'newly-readable', rank: 1 }]) + .mockResolvedValueOnce([{ id: 'newly-readable', rank: 1 }]) + .mockResolvedValueOnce([{ id: 'newly-readable', filename: 'Visible file' }]) + + const result = await getDocuments('knowledge-1', { limit: 1 }, 'request-1', access) + + expect(resolve).toHaveBeenCalledWith(['newly-readable']) + expect(result.documents).toMatchObject([{ id: 'newly-readable', filename: 'Visible file' }]) + }) +}) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index ca05d231cc3..8a555fb1834 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -70,9 +70,13 @@ import { EXACT_EMPTY_DURABLE_SECRET_PROVENANCE, mergeDurableSecretProvenance, } from '@/lib/execution/durable-secret-provenance' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' +import { + knowledgeAccessCondition, + knowledgeMetadataCandidateAccessCondition, +} from '@/lib/knowledge/access/predicate' import { type KnowledgeAccessScope, + MAX_KNOWLEDGE_ACCESS_CANDIDATES, SYSTEM_ACCESS_SCOPE, type SystemAccessScope, } from '@/lib/knowledge/access/types' @@ -138,6 +142,7 @@ import { } from '@/lib/knowledge/embedding-models' import { generateEmbeddings, type KbEmbeddingTarget } from '@/lib/knowledge/embeddings' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' +import { type KnowledgeReadAccess, knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import { bindKnowledgeDocumentFieldSecretProvenance, createKnowledgeDocumentSourceValue, @@ -2456,7 +2461,7 @@ export async function getDocuments( tagFilters?: TagFilterCondition[] }, requestId: string, - access: KnowledgeAccessScope | SystemAccessScope + access: KnowledgeReadAccess ): Promise<{ documents: Array<{ id: string @@ -2517,7 +2522,6 @@ export async function getDocuments( eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - knowledgeAccessCondition(access), ] if (enabledFilter === 'enabled') { @@ -2538,14 +2542,6 @@ export async function getDocuments( } } - const totalResult = await db - .select({ count: sql`COUNT(*)` }) - .from(document) - .where(and(...whereConditions)) - - const total = Number(totalResult[0]?.count ?? 0) - const hasMore = offset + limit < total - const getOrderByColumn = () => { switch (sortBy) { case 'filename': @@ -2571,50 +2567,117 @@ export async function getDocuments( const secondaryOrderBy = sortBy === 'filename' ? desc(document.uploadedAt) : asc(document.filename) - const documents = await db - .select({ - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileUrl: document.fileUrl, - fileSize: document.fileSize, - mimeType: document.mimeType, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - processingStatus: document.processingStatus, - processingStartedAt: document.processingStartedAt, - processingCompletedAt: document.processingCompletedAt, - processingError: document.processingError, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - tag1: document.tag1, - tag2: document.tag2, - tag3: document.tag3, - tag4: document.tag4, - tag5: document.tag5, - tag6: document.tag6, - tag7: document.tag7, - number1: document.number1, - number2: document.number2, - number3: document.number3, - number4: document.number4, - number5: document.number5, - date1: document.date1, - date2: document.date2, - boolean1: document.boolean1, - boolean2: document.boolean2, - boolean3: document.boolean3, - connectorId: document.connectorId, - connectorType: knowledgeConnector.connectorType, - sourceUrl: document.sourceUrl, - }) - .from(document) - .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) - .where(and(...whereConditions)) - .orderBy(primaryOrderBy, secondaryOrderBy) - .limit(limit) - .offset(offset) + const readDocuments = () => + db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileUrl: document.fileUrl, + fileSize: document.fileSize, + mimeType: document.mimeType, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + processingStatus: document.processingStatus, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + processingError: document.processingError, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + tag1: document.tag1, + tag2: document.tag2, + tag3: document.tag3, + tag4: document.tag4, + tag5: document.tag5, + tag6: document.tag6, + tag7: document.tag7, + number1: document.number1, + number2: document.number2, + number3: document.number3, + number4: document.number4, + number5: document.number5, + date1: document.date1, + date2: document.date2, + boolean1: document.boolean1, + boolean2: document.boolean2, + boolean3: document.boolean3, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + + let total = 0 + for await (const accessCondition of knowledgeReadAccessBatches(access, whereConditions)) { + const [counts] = await db + .select({ count: sql`COUNT(*)` }) + .from(document) + .where(and(...whereConditions, accessCondition)) + total += Number(counts?.count ?? 0) + } + + let documents: Awaited> = [] + if (!('get' in access)) { + documents = await readDocuments() + .where(and(...whereConditions, knowledgeAccessCondition(access))) + .orderBy(primaryOrderBy, secondaryOrderBy) + .limit(limit) + .offset(offset) + } else { + const identity = await access.get() + const rankedCandidates = db + .select({ + id: document.id, + rank: sql`row_number() over (order by ${primaryOrderBy}, ${secondaryOrderBy}, ${asc(document.id)})` + .mapWith(Number) + .as('read_rank'), + }) + .from(document) + .where(and(...whereConditions, knowledgeMetadataCandidateAccessCondition(identity))) + .as('knowledge_document_candidates') + let remainingOffset = offset + let lastRank = 0 + while (documents.length < limit) { + const candidates = await db + .select({ id: rankedCandidates.id, rank: rankedCandidates.rank }) + .from(rankedCandidates) + .where(sql`${rankedCandidates.rank} > ${lastRank}`) + .orderBy(asc(rankedCandidates.rank)) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + if (candidates.length === 0) break + const candidateIds = candidates.map((candidate) => candidate.id) + const scope = await access.getForDocuments(candidateIds) + const accessCondition = knowledgeAccessCondition(scope) + const visible = await db + .select({ id: document.id, rank: rankedCandidates.rank }) + .from(document) + .innerJoin(rankedCandidates, eq(document.id, rankedCandidates.id)) + .where(and(...whereConditions, inArray(document.id, candidateIds), accessCondition)) + .orderBy(asc(rankedCandidates.rank)) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + if (remainingOffset >= visible.length) remainingOffset -= visible.length + else { + const pageIds = visible + .slice(remainingOffset, remainingOffset + limit - documents.length) + .map((row) => row.id) + remainingOffset = 0 + if (pageIds.length) { + documents.push( + ...(await readDocuments() + .innerJoin(rankedCandidates, eq(document.id, rankedCandidates.id)) + .where(and(...whereConditions, accessCondition, inArray(document.id, pageIds))) + .orderBy(asc(rankedCandidates.rank)) + .limit(limit)) + ) + } + } + if (candidates.length < MAX_KNOWLEDGE_ACCESS_CANDIDATES) break + lastRank = candidates[candidates.length - 1].rank + } + } + const hasMore = offset + limit < total logger.info( `[${requestId}] Retrieved ${documents.length} documents (${offset}-${offset + documents.length} of ${total}) for knowledge base ${knowledgeBaseId}` @@ -3053,7 +3116,7 @@ export async function bulkDocumentOperation( knowledgeBaseId: string, operation: 'enable' | 'disable' | 'delete', documentIds: string[], - access: KnowledgeAccessScope, + access: KnowledgeReadAccess, requestId: string ): Promise<{ success: boolean @@ -3069,22 +3132,31 @@ export async function bulkDocumentOperation( `[${requestId}] Starting bulk ${operation} operation on ${documentIds.length} documents in knowledge base ${knowledgeBaseId}` ) - const documentsToUpdate = await db - .select({ - id: document.id, - enabled: document.enabled, - }) - .from(document) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - inArray(document.id, documentIds), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - knowledgeAccessCondition(access) - ) + const candidateConditions = [ + eq(document.knowledgeBaseId, knowledgeBaseId), + inArray(document.id, documentIds), + ] + const documentsToUpdate: { id: string; enabled: boolean }[] = [] + for await (const accessCondition of knowledgeReadAccessBatches(access, candidateConditions)) { + documentsToUpdate.push( + ...(await db + .select({ + id: document.id, + enabled: document.enabled, + }) + .from(document) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + inArray(document.id, documentIds), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + accessCondition + ) + )) ) + } if (documentsToUpdate.length === 0) { throw new OrchestrationError('not_found', 'No valid documents found to update') @@ -3147,7 +3219,7 @@ export async function bulkDocumentOperationByFilter( knowledgeBaseId: string, operation: 'enable' | 'disable' | 'delete', enabledFilter: 'all' | 'enabled' | 'disabled' | undefined, - access: KnowledgeAccessScope, + access: KnowledgeReadAccess, requestId: string ): Promise<{ success: boolean @@ -3167,8 +3239,6 @@ export async function bulkDocumentOperationByFilter( eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - /** "Every document" means every document the caller can see. */ - knowledgeAccessCondition(access), ] if (enabledFilter === 'enabled') { @@ -3177,33 +3247,36 @@ export async function bulkDocumentOperationByFilter( whereConditions.push(eq(document.enabled, false)) } - let updateResult: Array<{ + const updateResult: Array<{ id: string enabled?: boolean deletedAt?: Date | null - }> - - if (operation === 'delete') { - const matchingDocs = await db - .select({ id: document.id }) - .from(document) - .where(and(...whereConditions)) - - const deletedIds = matchingDocs.map((doc) => doc.id) - const deletedCount = await deleteDocumentsByLifecyclePolicy(deletedIds, requestId) - updateResult = deletedIds.slice(0, deletedCount).map((id) => ({ id })) - } else { - const enabled = operation === 'enable' + }> = [] + + for await (const accessCondition of knowledgeReadAccessBatches(access, whereConditions)) { + if (operation === 'delete') { + const matchingDocs = await db + .select({ id: document.id }) + .from(document) + .where(and(...whereConditions, accessCondition)) + + const deletedIds = matchingDocs.map((doc) => doc.id) + const deletedCount = await deleteDocumentsByLifecyclePolicy(deletedIds, requestId) + updateResult.push(...deletedIds.slice(0, deletedCount).map((id) => ({ id }))) + } else { + const enabled = operation === 'enable' - updateResult = await db - .update(document) - .set({ - enabled, - }) - .where(and(...whereConditions)) - .returning({ id: document.id, enabled: document.enabled }) + updateResult.push( + ...(await db + .update(document) + .set({ + enabled, + }) + .where(and(...whereConditions, accessCondition)) + .returning({ id: document.id, enabled: document.enabled })) + ) + } } - const successCount = updateResult.length logger.info( diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.ts b/apps/sim/lib/knowledge/orchestration/connector-access.ts index 0612398729d..bf4aab69bfc 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.ts @@ -363,6 +363,16 @@ export async function performUpdateKnowledgeConnectorAccess( credentialGroupId: target.binding.credentialGroupId, credentialGroupOptionId: target.binding.credentialGroupOptionId, }) + /** A new repository identity cannot inherit observations collected before it was verified. */ + if ( + existing.connectorType === 'github' && + target.binding.sourceConfig.githubRepositoryId !== + (existing.sourceConfig as Record).githubRepositoryId + ) { + await tx + .delete(knowledgeConnectorMember) + .where(eq(knowledgeConnectorMember.connectorId, connectorId)) + } const [row] = await tx .update(knowledgeConnector) .set({ diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 5765d5b8604..8c8d44d4087 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -650,6 +650,11 @@ export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperatio } /** Resolves the payer only when a source change will queue synchronization. */ resolveBillingAttribution: () => Promise + /** Canonicalizes provider identities through the authorized application caller. */ + prepareSourceConfig?: ( + connector: KnowledgeConnectorRow, + sourceConfig: Record + ) => Promise> /** * Validates a replacement `sourceConfig` against the live source. Supplied by * the caller because resolving the connector's token needs the requesting @@ -793,7 +798,9 @@ export async function performUpdateKnowledgeConnector( let sourceConfigToStore = updates.sourceConfig if (updates.sourceConfig !== undefined) { const accessMode = existing.accessMode as ConnectorAccessMode - let nextSourceConfig = updates.sourceConfig + let nextSourceConfig = params.prepareSourceConfig + ? await params.prepareSourceConfig(existing, updates.sourceConfig) + : updates.sourceConfig if (aclIsDerived(accessMode)) { /** A derived-ACL mode has no listing cap; a save may refuse one, never store one. */ const { CONNECTOR_REGISTRY } = await import('@/connectors/registry.server') @@ -909,6 +916,8 @@ export async function performUpdateKnowledgeConnector( isNull(knowledgeConnector.deletedAt), ] updateConditions.push(eq(knowledgeConnector.status, existing.status)) + if (sourceConfigToStore !== undefined) + updateConditions.push(eq(knowledgeConnector.updatedAt, existing.updatedAt)) if (syncsPerMember) { updateConditions.push(eq(knowledgeConnector.memberSyncStatus, existing.memberSyncStatus)) } diff --git a/apps/sim/lib/knowledge/read-access.test.ts b/apps/sim/lib/knowledge/read-access.test.ts new file mode 100644 index 00000000000..9d0d1179fab --- /dev/null +++ b/apps/sim/lib/knowledge/read-access.test.ts @@ -0,0 +1,75 @@ +/** @vitest-environment node */ +import { document } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { eq, gt } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + type KnowledgeAccessProvider, + type KnowledgeAccessScope, + MAX_KNOWLEDGE_ACCESS_CANDIDATES, +} from '@/lib/knowledge/access/types' +import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' + +const identity: KnowledgeAccessScope = { kind: 'user', userId: 'reader', tokens: ['org'] } + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) + +describe('knowledgeReadAccessBatches', () => { + it('continues after a full denied candidate page using fixed source IDs only', async () => { + const first = Array.from({ length: MAX_KNOWLEDGE_ACCESS_CANDIDATES }, (_, index) => ({ + connectorId: `source-${String(index).padStart(4, '0')}`, + })) + queueTableRows(document, first) + queueTableRows(document, [{ connectorId: 'source-last' }]) + const resolve = vi.fn(async () => identity) + const provider: KnowledgeAccessProvider = { + get: async () => identity, + getForConnectors: resolve, + getForDocuments: async () => identity, + } + const filter = eq(document.knowledgeBaseId, 'one-index') + const batches = [] + for await (const predicate of knowledgeReadAccessBatches(provider, [filter])) + batches.push(predicate) + expect(batches).toHaveLength(3) + expect(resolve.mock.calls).toHaveLength(2) + expect(resolve).toHaveBeenNthCalledWith( + 1, + first.map((row) => row.connectorId), + undefined + ) + expect(resolve).toHaveBeenNthCalledWith(2, ['source-last'], undefined) + expect(dbChainMockFns.selectDistinct).toHaveBeenCalledWith({ + connectorId: document.connectorId, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + expect(gt).toHaveBeenCalledWith(document.connectorId, first.at(-1)!.connectorId) + }) + + it('does not enumerate sources after a satisfied ordinary existence probe', async () => { + const resolve = vi.fn(async () => identity) + const provider: KnowledgeAccessProvider = { + get: async () => identity, + getForConnectors: resolve, + getForDocuments: async () => identity, + } + for await (const predicate of knowledgeReadAccessBatches(provider, [])) { + expect(predicate).toBeDefined() + break + } + expect(dbChainMockFns.selectDistinct).not.toHaveBeenCalled() + expect(resolve).not.toHaveBeenCalled() + }) + + it('honors cancellation before returning any predicate', async () => { + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect( + knowledgeReadAccessBatches(identity, [], controller.signal).next() + ).rejects.toThrow('cancelled') + expect(dbChainMockFns.selectDistinct).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/read-access.ts b/apps/sim/lib/knowledge/read-access.ts new file mode 100644 index 00000000000..e1942b698a2 --- /dev/null +++ b/apps/sim/lib/knowledge/read-access.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { document, knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { and, asc, eq, gt, inArray, isNotNull, not, type SQL } from 'drizzle-orm' +import { + knowledgeAccessCondition, + knowledgeMetadataCandidateAccessCondition, +} from '@/lib/knowledge/access/predicate' +import { + type KnowledgeAccessProvider, + type KnowledgeAccessScope, + MAX_KNOWLEDGE_ACCESS_CANDIDATES, + type SystemAccessScope, +} from '@/lib/knowledge/access/types' + +export type KnowledgeReadAccess = KnowledgeAccessScope | SystemAccessScope | KnowledgeAccessProvider + +/** + * Streams disjoint, fully authorized document predicates for an existing reader's filters. + * Only connector IDs are selected before live proof; totals and metadata use the yielded + * full predicate. Paging avoids making unrelated sources a prerequisite for any one batch. + */ +export async function* knowledgeReadAccessBatches( + access: KnowledgeReadAccess, + conditions: readonly (SQL | undefined)[], + signal?: AbortSignal +): AsyncGenerator { + signal?.throwIfAborted() + const provider = 'get' in access ? access : undefined + const scope = 'get' in access ? await access.get() : access + const ordinary = knowledgeAccessCondition(scope) + yield ordinary + if (!provider || scope.kind !== 'user') return + + let cursor: string | undefined + while (true) { + signal?.throwIfAborted() + const rows = await db + .selectDistinct({ connectorId: document.connectorId }) + .from(document) + .innerJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + ...conditions, + knowledgeMetadataCandidateAccessCondition(scope), + not(ordinary), + isNotNull(document.connectorId), + cursor ? gt(document.connectorId, cursor) : undefined + ) + ) + .orderBy(asc(document.connectorId)) + .limit(MAX_KNOWLEDGE_ACCESS_CANDIDATES) + if (rows.length === 0) return + const connectorIds = rows.flatMap(({ connectorId }) => (connectorId ? [connectorId] : [])) + if (connectorIds.length === 0) return + const proof = await provider.getForConnectors(connectorIds, signal) + yield and( + not(ordinary), + inArray(document.connectorId, connectorIds), + knowledgeAccessCondition(proof) + )! + if (rows.length < MAX_KNOWLEDGE_ACCESS_CANDIDATES) return + cursor = connectorIds[connectorIds.length - 1] + } +} diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 72c65866d91..e8edea4491d 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -9,7 +9,11 @@ import { schemaMock, } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { WORKSPACE_ACCESS_TOKENS } from '@/lib/knowledge/access/types' +import { + type KnowledgeAccessProvider, + type UserAccessScope, + WORKSPACE_ACCESS_TOKENS, +} from '@/lib/knowledge/access/types' import { buildTagFilterCondition } from '@/lib/knowledge/documents/tag-filter' import { executeKeywordSearch, @@ -470,3 +474,154 @@ describe('workspace search filters before ranking', () => { expectScopeOnEveryQuery() }) }) + +describe('live repository authorization follows ranked candidates', () => { + const identity: UserAccessScope = { + kind: 'user', + userId: 'reader', + tokens: ['org', 's:github-repositories:-:42'], + } + const allowed: UserAccessScope = { + ...identity, + githubInstallationGrants: [ + { + connectorId: 'allowed-source', + contentCredentialId: 'installation-credential', + readerCredentialId: 'reader-credential', + repositoryId: '101', + readerSubjectToken: 's:github-repositories:-:42', + }, + ], + } + const candidate = (id: string, connectorId: string) => ({ + id, + documentId: `doc-${id}`, + connectorId, + installationSource: true, + distance: 0.1, + }) + const getForConnectors = vi.fn() + const provider: KnowledgeAccessProvider = { + get: async () => identity, + getForConnectors, + getForDocuments: async () => allowed, + } + const params: SearchParams = { + knowledgeBaseIds: ['org-index'], + topK: 1, + access: identity, + accessProvider: provider, + queryVector: { vector: '[0.1,0.2]', dimensions: 1536 }, + distanceThreshold: 0.8, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'release' }], + } + + beforeEach(() => { + resetDbChainMock() + getForConnectors.mockReset().mockResolvedValue(allowed) + }) + + afterEach(() => vi.useRealTimers()) + + it.each(['vector', 'tag-vector', 'tags', 'keyword'] as const)( + '%s ranks identifiers before verification and loads content under the full predicate', + async (mode) => { + queueTableRows(schemaMock.embedding, [candidate('selected', 'allowed-source')]) + queueTableRows(schemaMock.embedding, [{ id: 'selected', content: 'verified result' }]) + const rows = + mode === 'vector' + ? await handleVectorOnlySearch(params) + : mode === 'tag-vector' + ? await handleTagAndVectorSearch(params) + : mode === 'tags' + ? await handleTagOnlySearch(params) + : await executeKeywordSearch({ + ...params, + query: 'release', + queryVector: params.queryVector!, + }) + expect(rows).toEqual([{ id: 'selected', content: 'verified result' }]) + expect(getForConnectors).toHaveBeenCalledWith(['allowed-source'], undefined) + expect(Object.keys(dbChainMockFns.select.mock.calls[0][0]).sort()).toEqual( + [ + 'id', + 'documentId', + 'connectorId', + 'installationSource', + ...(mode === 'keyword' ? ['keywordRank'] : mode === 'tags' ? [] : ['distance']), + ].sort() + ) + expect(dbChainMockFns.select.mock.invocationCallOrder[0]).toBeLessThan( + getForConnectors.mock.invocationCallOrder[0] + ) + expect(getForConnectors.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[1] + ) + const fullPredicate = dbChainMockFns.where.mock.calls[1][0] + const serializedPredicate = JSON.stringify(fullPredicate) + expect(serializedPredicate).toContain('github_read_grant') + expect(serializedPredicate).toContain('allowed-source') + expect(serializedPredicate).toContain('reader-credential') + expect( + hasMockCondition( + fullPredicate, + (node) => + node.type === 'inArray' && + node.column === schemaMock.embedding.id && + Array.isArray(node.values) && + node.values.length === 1 && + node.values[0] === 'selected' + ) + ).toBe(true) + } + ) + + it('refills after a denied repository instead of letting its matches consume the result limit', async () => { + getForConnectors.mockResolvedValueOnce(identity) + queueTableRows(schemaMock.embedding, [candidate('denied', 'revoked-source')]) + queueTableRows(schemaMock.embedding, []) + queueTableRows(schemaMock.embedding, [candidate('selected', 'allowed-source')]) + queueTableRows(schemaMock.embedding, [{ id: 'selected', content: 'verified result' }]) + const rows = await handleTagOnlySearch(params) + expect(rows).toEqual([{ id: 'selected', content: 'verified result' }]) + expect(getForConnectors.mock.calls.map(([ids]) => ids)).toEqual([ + ['revoked-source'], + ['allowed-source'], + ]) + expect(dbChainMockFns.offset.mock.calls).toEqual([[0], [0]]) + const refillPredicate = JSON.stringify(dbChainMockFns.where.mock.calls[2][0]) + expect(refillPredicate).toContain('NOT') + expect(refillPredicate).toContain('revoked-source') + }) + + it('retains a completed authorized result when the next candidate page exhausts its deadline', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(10000)) + getForConnectors.mockImplementation(async () => { + vi.setSystemTime(new Date(19000)) + return allowed + }) + queueTableRows(schemaMock.embedding, [ + candidate('selected', 'allowed-source'), + candidate('slow', 'slow-source'), + ]) + queueTableRows(schemaMock.embedding, [{ id: 'selected', content: 'verified result' }]) + expect(await handleTagOnlySearch({ ...params, topK: 2 })).toEqual([ + { id: 'selected', content: 'verified result' }, + ]) + expect(getForConnectors).toHaveBeenCalledOnce() + }) + + it('propagates caller cancellation before content hydration', async () => { + const cancellation = new AbortController() + getForConnectors.mockImplementation(async () => { + cancellation.abort(new Error('Search cancelled')) + return allowed + }) + queueTableRows(schemaMock.embedding, [candidate('selected', 'allowed-source')]) + await expect(handleTagOnlySearch({ ...params, signal: cancellation.signal })).rejects.toThrow( + 'Search cancelled' + ) + expect(dbChainMockFns.select).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index 715f9a8aaca..dbe5d64735b 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -3,8 +3,11 @@ import { document, embedding, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' -import type { KnowledgeAccessScope } from '@/lib/knowledge/access/types' +import { + knowledgeAccessCondition, + knowledgeMetadataCandidateAccessCondition, +} from '@/lib/knowledge/access/predicate' +import type { KnowledgeAccessProvider, KnowledgeAccessScope } from '@/lib/knowledge/access/types' import type { KbEmbeddingDimensions } from '@/lib/knowledge/embedding-models' import { workspaceSearchFilterConditions } from '@/lib/knowledge/search/filter-conditions' import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' @@ -79,13 +82,18 @@ export interface DocumentMetadata { */ export async function getDocumentMetadataByIds( documentIds: string[], - access: KnowledgeAccessScope + access: KnowledgeAccessScope, + accessProvider?: KnowledgeAccessProvider, + signal?: AbortSignal ): Promise> { if (documentIds.length === 0) { return {} } const uniqueIds = [...new Set(documentIds)] + const authorizedAccess = accessProvider + ? await accessProvider.getForDocuments(uniqueIds, signal) + : access const documents = await db .select({ id: document.id, @@ -102,7 +110,7 @@ export async function getDocumentMetadataByIds( eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - knowledgeAccessCondition(access) + knowledgeAccessCondition(authorizedAccess) ) ) @@ -168,6 +176,8 @@ export interface SearchParams { topK: number /** What the caller may read; every leg applies it. Required so no leg can be written without it. */ access: KnowledgeAccessScope + accessProvider?: KnowledgeAccessProvider + signal?: AbortSignal structuredFilters?: StructuredFilter[] filters?: WorkspaceSearchFilters queryVector?: KnowledgeQueryVector @@ -397,7 +407,11 @@ const FTS_CONFIG = 'english' * overlaps the caller's tokens. Every leg spreads this helper rather than * listing the predicates itself, so no leg can drift from the others. */ -function getVisibilityConditions(access: KnowledgeAccessScope, filters?: WorkspaceSearchFilters) { +function getVisibilityConditions( + access: KnowledgeAccessScope, + filters?: WorkspaceSearchFilters, + accessCondition: SQL = knowledgeAccessCondition(access) +) { return [ eq(embedding.enabled, true), eq(document.enabled, true), @@ -405,11 +419,128 @@ function getVisibilityConditions(access: KnowledgeAccessScope, filters?: Workspa eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - knowledgeAccessCondition(access), + accessCondition, ...workspaceSearchFilterConditions(filters), ] } +interface SearchReadCandidate { + id: string + documentId: string + connectorId: string | null + installationSource: boolean +} + +/** Only opaque identifiers leave candidate ranking; content stays behind the full read predicate. */ +const SEARCH_READ_CANDIDATE_FIELDS = { + id: embedding.id, + documentId: document.id, + connectorId: document.connectorId, + installationSource: sql`EXISTS ( + SELECT 1 FROM ${knowledgeConnector} + WHERE ${knowledgeConnector.id} = ${document.connectorId} + AND ${knowledgeConnector.connectorType} = 'github' + AND ${knowledgeConnector.sourceConfig}::jsonb ? 'githubRepositoryId' + )`, +} + +const LIVE_SEARCH_PAGE_SIZE = 200 +const LIVE_SEARCH_BUDGET_MS = 8000 + +/** + * Verification follows ranked candidates, never the organization's source order. Denied + * repositories are excluded on refill, so many matches from one revoked source cannot + * consume every result slot. The existing vector tuple budget also bounds candidate work. + */ +async function selectAuthorizedSearchResults(input: { + accessProvider: KnowledgeAccessProvider + signal?: AbortSignal + topK: number + selectPage: ( + limit: number, + offset: number, + excludedSources: readonly string[] + ) => Promise + hydrate: (ids: string[], access: KnowledgeAccessScope) => Promise +}): Promise { + const deadline = Date.now() + LIVE_SEARCH_BUDGET_MS + const pageSize = Math.min(LIVE_SEARCH_PAGE_SIZE, Math.max(input.topK, 20)) + const results = new Map() + const excludedSources = new Set() + let scanned = 0 + let offset = 0 + while ( + results.size < input.topK && + scanned < Number(HNSW_MAX_SCAN_TUPLES) && + Date.now() < deadline + ) { + input.signal?.throwIfAborted() + const candidates = await input.selectPage(pageSize, offset, [...excludedSources]) + if (!candidates.length) break + scanned += candidates.length + const connectorIds = [ + ...new Set( + candidates.flatMap((candidate) => (candidate.connectorId ? [candidate.connectorId] : [])) + ), + ] + const access = await input.accessProvider.getForConnectors(connectorIds, input.signal) + input.signal?.throwIfAborted() + const grantedSources = new Set( + access.kind === 'user' + ? (access.githubInstallationGrants?.map((grant) => grant.connectorId) ?? []) + : [] + ) + const excludedBefore = excludedSources.size + for (const candidate of candidates) { + if ( + candidate.installationSource && + candidate.connectorId && + !grantedSources.has(candidate.connectorId) + ) + excludedSources.add(candidate.connectorId) + } + const hydrated = await input.hydrate( + candidates.map((candidate) => candidate.id), + access + ) + const byId = new Map(hydrated.map((row) => [row.id, row])) + for (const candidate of candidates) { + const row = byId.get(candidate.id) + if (row) results.set(row.id, row) + if (results.size === input.topK) break + } + if (excludedSources.size > excludedBefore) offset = 0 + else { + offset += candidates.length + if (candidates.length < pageSize) break + } + } + input.signal?.throwIfAborted() + return [...results.values()] +} + +function excludeSearchSources(sourceIds: readonly string[]): SQL | undefined { + return sourceIds.length + ? sql`(${document.connectorId} IS NULL OR NOT (${inArray(document.connectorId, [...sourceIds])}))` + : undefined +} + +function hydrateSearchCandidates( + ids: string[], + access: KnowledgeAccessScope, + distance: SQL | SQL.Aliased, + filters: WorkspaceSearchFilters | undefined, + conditions: (SQL | undefined)[] +) { + return db + .select(getSearchResultFields(distance)) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and(inArray(embedding.id, ids), ...getVisibilityConditions(access, filters), ...conditions) + ) +} + /** Candidates each hybrid leg retrieves before the fused list is trimmed to `topK`. */ const HYBRID_CANDIDATE_MIN = 50 const HYBRID_CANDIDATE_MAX = 200 @@ -440,6 +571,45 @@ export async function handleTagOnlySearch(params: SearchParams): Promise + db + .select(SEARCH_READ_CANDIDATE_FIELDS) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and( + ...conditions, + ...getVisibilityConditions( + access, + params.filters, + knowledgeMetadataCandidateAccessCondition(access) + ), + excludeSearchSources(excludedSources) + ) + ) + .orderBy(embedding.id) + .limit(limit) + .offset(offset), + hydrate: (ids, authorized) => + hydrateSearchCandidates( + ids, + authorized, + sql`0`.as('distance'), + params.filters, + conditions + ), + }) + } + if (strategy.useParallel) { const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 @@ -486,6 +656,11 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise selectRankedVectorResults( executor, @@ -520,6 +695,44 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise a.distance - b.distance) } +/** The vector transaction ends after ranking, before any provider authorization request starts. */ +function selectLiveVectorResults( + params: SearchParams, + accessProvider: KnowledgeAccessProvider, + distance: SQL, + filters: (SQL | undefined)[] +): Promise { + const conditions = [inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds), ...filters] + return selectAuthorizedSearchResults({ + accessProvider, + signal: params.signal, + topK: params.topK, + selectPage: (limit, offset, excludedSources) => + withVectorScanSettings((executor) => + executor + .select({ ...SEARCH_READ_CANDIDATE_FIELDS, distance: distance.as('distance') }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and( + ...conditions, + ...getVisibilityConditions( + params.access, + params.filters, + knowledgeMetadataCandidateAccessCondition(params.access) + ), + excludeSearchSources(excludedSources) + ) + ) + .orderBy(distance, embedding.id) + .limit(limit) + .offset(offset) + ), + hydrate: (ids, authorized) => + hydrateSearchCandidates(ids, authorized, distance.as('distance'), params.filters, conditions), + }) +} + /** * Sort only chunk identities and distances before loading result content. Carrying * full chunk rows through the vector sort can spill to disk. The bounded subquery @@ -553,6 +766,8 @@ export interface KeywordSearchParams { knowledgeBaseIds: string[] topK: number access: KnowledgeAccessScope + accessProvider?: KnowledgeAccessProvider + signal?: AbortSignal query: string /** Query embedding, so keyword-only hits still carry a real cosine distance. */ queryVector: KnowledgeQueryVector @@ -570,11 +785,9 @@ export interface KeywordSearchParams { * leg there is no distance threshold — surfacing exact-token matches that are * semantically distant is the entire point of this leg. * - * Candidate gathering mirrors the vector leg's `getQueryStrategy`: across many - * knowledge bases a single global `LIMIT` lets whichever base ranks strongest - * lexically consume every slot, so an exact-token hit in a smaller base would - * never reach fusion. Both legs must draw candidates the same way, or rank - * fusion is combining rankings taken over differently-shaped pools. + * Candidate gathering mirrors the vector leg: resolved scopes use the same + * per-base strategy, and live user scopes verify bounded pages from the same + * global ranking pool before hydrating content. * * Ranking and hydration are two steps on purpose. Projecting the cosine * distance in the ranking query makes Postgres detoast the chunk's vector and @@ -597,6 +810,46 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise ? getStructuredTagFilters(structuredFilters, embedding) : [] + if (params.accessProvider && access.kind === 'user') { + const conditions = [ + inArray(embedding.knowledgeBaseId, knowledgeBaseIds), + sql`${embedding.contentTsv} @@ ${tsQuery}`, + ...tagFilterConditions, + ] + return selectAuthorizedSearchResults({ + accessProvider: params.accessProvider, + signal: params.signal, + topK, + selectPage: (limit, offset, excludedSources) => + db + .select({ ...SEARCH_READ_CANDIDATE_FIELDS, keywordRank: rankExpr.as('keyword_rank') }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and( + ...conditions, + ...getVisibilityConditions( + access, + params.filters, + knowledgeMetadataCandidateAccessCondition(access) + ), + excludeSearchSources(excludedSources) + ) + ) + .orderBy(sql`${rankExpr} DESC`, embedding.id) + .limit(limit) + .offset(offset), + hydrate: (ids, authorized) => + hydrateSearchCandidates( + ids, + authorized, + embeddingDistance(queryVector.dimensions, queryVector.vector).as('distance'), + params.filters, + conditions + ), + }) + } + const rankConditions = (kbScope: SQL | undefined) => and( kbScope, @@ -743,6 +996,12 @@ export async function handleTagAndVectorSearch(params: SearchParams): Promise selectRankedVectorResults( executor, @@ -771,6 +1030,8 @@ export interface ExecuteKnowledgeSearchParams { topK: number /** What the caller may read; resolved from the principal by the use case, never from input. */ access: KnowledgeAccessScope + accessProvider?: KnowledgeAccessProvider + signal?: AbortSignal searchMode: KnowledgeSearchMode /** Lets a recently modified document edge past a stale one of similar relevance; off by default. */ boostRecency?: boolean @@ -812,6 +1073,8 @@ export async function executeKnowledgeSearch( topK, structuredFilters, access, + accessProvider: params.accessProvider, + signal: params.signal, filters: params.filters, }) } @@ -836,6 +1099,8 @@ export async function executeKnowledgeSearch( queryVector, distanceThreshold, access, + accessProvider: params.accessProvider, + signal: params.signal, filters: params.filters, }) : handleVectorOnlySearch({ @@ -844,6 +1109,8 @@ export async function executeKnowledgeSearch( queryVector, distanceThreshold, access, + accessProvider: params.accessProvider, + signal: params.signal, filters: params.filters, }) @@ -863,6 +1130,8 @@ export async function executeKnowledgeSearch( queryVector, structuredFilters, access, + accessProvider: params.accessProvider, + signal: params.signal, filters: params.filters, }).catch((error) => { logger.warn('Keyword search leg failed; falling back to vector-only results', { diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index 6d1d112cf60..8f837de9ccb 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -12,13 +12,12 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNotNull, isNull, or, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' -import type { KnowledgeAccessScope } from '@/lib/knowledge/access/types' import { getSlotsForFieldType, isValidSlotForFieldType, SUPPORTED_FIELD_TYPES, } from '@/lib/knowledge/constants' +import { type KnowledgeReadAccess, knowledgeReadAccessBatches } from '@/lib/knowledge/read-access' import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types' import type { CreateTagDefinitionData, @@ -806,7 +805,7 @@ export async function updateTagDefinition( export async function getTagUsage( knowledgeBaseId: string, requestId: string, - access: KnowledgeAccessScope + access: KnowledgeReadAccess ): Promise< Array<{ tagName: string @@ -832,7 +831,6 @@ export async function getTagUsage( eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), - knowledgeAccessCondition(access), isNotNull(sql`${sql.raw(tagSlot)}`), ] @@ -840,14 +838,19 @@ export async function getTagUsage( whereConditions.push(sql`${sql.raw(tagSlot)} != ''`) } - const documentsWithTag = await db - .select({ - id: document.id, - filename: document.filename, - tagValue: sql`${sql.raw(tagSlot)}::text`, - }) - .from(document) - .where(and(...whereConditions)) + const documentsWithTag: { id: string; filename: string; tagValue: string }[] = [] + for await (const accessCondition of knowledgeReadAccessBatches(access, whereConditions)) { + documentsWithTag.push( + ...(await db + .select({ + id: document.id, + filename: document.filename, + tagValue: sql`${sql.raw(tagSlot)}::text`, + }) + .from(document) + .where(and(...whereConditions, accessCondition))) + ) + } usage.push({ tagName: def.displayName, @@ -871,7 +874,7 @@ export async function getTagUsage( */ export async function getTagUsageStats( knowledgeBaseId: string, - access: KnowledgeAccessScope, + access: KnowledgeReadAccess, requestId: string ): Promise< Array<{ @@ -890,42 +893,54 @@ export async function getTagUsageStats( const tagSlot = def.tagSlot validateTagSlot(tagSlot) - const docCountResult = await db - .select({ count: sql`count(*)` }) - .from(document) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - knowledgeAccessCondition(access), - sql`${sql.raw(tagSlot)} IS NOT NULL` + const conditions = [ + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + ] + let documentCount = 0 + let chunkCount = 0 + for await (const accessCondition of knowledgeReadAccessBatches(access, conditions)) { + const docCountResult = await db + .select({ count: sql`count(*)` }) + .from(document) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + accessCondition, + sql`${sql.raw(tagSlot)} IS NOT NULL` + ) ) - ) - const chunkCountResult = await db - .select({ count: sql`count(*)` }) - .from(embedding) - .innerJoin(document, eq(embedding.documentId, document.id)) - .where( - and( - eq(embedding.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt), - knowledgeAccessCondition(access), - sql`${sql.raw(`embedding.${tagSlot}`)} IS NOT NULL` + const chunkCountResult = await db + .select({ count: sql`count(*)` }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and( + eq(embedding.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + accessCondition, + sql`${sql.raw(`embedding.${tagSlot}`)} IS NOT NULL` + ) ) - ) + documentCount += Number(docCountResult[0]?.count || 0) + chunkCount += Number(chunkCountResult[0]?.count || 0) + } stats.push({ id: def.id, tagSlot: def.tagSlot, displayName: def.displayName, fieldType: def.fieldType, - documentCount: Number(docCountResult[0]?.count || 0), - chunkCount: Number(chunkCountResult[0]?.count || 0), + documentCount, + chunkCount, }) } diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 292fd4921a9..bd68fa4f4cb 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -21,6 +21,14 @@ import { parseTokenServiceAccountSecretBlob, type TokenServiceAccountSecretBlob, } from '@/lib/credentials/token-service-accounts/server' +import { + parseGitHubInstallationBinding, + resolveGitHubInstallationAccessToken, +} from '@/lib/oauth/github-installation' +import { + GITHUB_INSTALLATION_PROVIDER_ID, + type GitHubInstallationRepositoryScope, +} from '@/lib/oauth/github-installation-types' import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, @@ -63,6 +71,8 @@ export interface CredentialTokenResolutionOptions { * mode so selector and ordinary calls share the same locks and dead flags. */ privacyMode?: 'selector' + /** GitHub installation content tokens may only address one connector repository. */ + githubRepositoryScope?: GitHubInstallationRepositoryScope } function privateCredentialIdentity(namespace: string, value: string): string { @@ -635,6 +645,7 @@ interface ServiceAccountTokenOptions { scopes?: string[] impersonateEmail?: string privacyMode?: 'selector' + githubRepositoryScope?: GitHubInstallationRepositoryScope } type ServiceAccountTokenResolver = ( @@ -648,6 +659,40 @@ type ServiceAccountTokenResolver = ( * generically: the stored token IS the access token. */ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record = { + [GITHUB_INSTALLATION_PROVIDER_ID]: async (credentialId, { githubRepositoryScope }) => { + if (!githubRepositoryScope) + throw new Error('GitHub installation tokens require a source repository') + const [row] = await db + .select({ + type: credential.type, + providerId: credential.providerId, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, + revokedAt: credential.revokedAt, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + if ( + row?.type !== 'service_account' || + row.providerId !== GITHUB_INSTALLATION_PROVIDER_ID || + row.revokedAt || + !row.encryptedServiceAccountKey || + row.encryptedServiceAccountKey.length > 16_384 + ) { + throw new Error('GitHub installation credential is unavailable') + } + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + const binding = parseGitHubInstallationBinding(JSON.parse(decrypted)) + if ( + row.providerSubjectId !== binding.installationId || + row.providerTenantId !== binding.accountId + ) { + throw new Error('GitHub installation credential identity does not match its binding') + } + return resolveGitHubInstallationAccessToken(binding, githubRepositoryScope) + }, [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId) => { const secret = await getAtlassianServiceAccountSecret(credentialId) return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } diff --git a/apps/sim/lib/oauth/github-installation-credential.test.ts b/apps/sim/lib/oauth/github-installation-credential.test.ts new file mode 100644 index 00000000000..29657563997 --- /dev/null +++ b/apps/sim/lib/oauth/github-installation-credential.test.ts @@ -0,0 +1,99 @@ +/** @vitest-environment node */ +import { credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + decryptSecret: vi.fn(), + parseBinding: vi.fn(), + resolveToken: vi.fn(), +})) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mocks.decryptSecret })) +vi.mock('@/lib/oauth/github-installation', () => ({ + parseGitHubInstallationBinding: mocks.parseBinding, + resolveGitHubInstallationAccessToken: mocks.resolveToken, +})) +vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, refreshOAuthToken: vi.fn() })) + +import { resolveServiceAccountToken } from '@/lib/oauth/credential-service' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' + +const row = { + type: 'service_account', + providerId: GITHUB_INSTALLATION_PROVIDER_ID, + providerSubjectId: '21', + providerTenantId: '11', + encryptedServiceAccountKey: 'encrypted', +} +const binding = { installationId: '21', accountId: '11' } + +beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(binding) }) + mocks.parseBinding.mockReturnValue(binding) + mocks.resolveToken.mockResolvedValue({ accessToken: 'ghs_contents' }) +}) + +describe('installation credential token dispatch', () => { + it('requires a repository scope before reading or decrypting a credential', async () => { + await expect( + resolveServiceAccountToken('credential-1', GITHUB_INSTALLATION_PROVIDER_ID) + ).rejects.toThrow('require a source repository') + expect(mocks.decryptSecret).not.toHaveBeenCalled() + }) + + it('dispatches only the validated installation credential and exact repository scope', async () => { + queueTableRows(credential, [row]) + const scope = { repositoryId: '101', repository: 'team/repo' } + expect( + await resolveServiceAccountToken( + 'credential-1', + GITHUB_INSTALLATION_PROVIDER_ID, + undefined, + undefined, + { githubRepositoryScope: scope } + ) + ).toEqual({ accessToken: 'ghs_contents' }) + expect(mocks.resolveToken).toHaveBeenCalledWith(binding, scope) + }) + + it.each([ + { ...row, type: 'oauth' }, + { ...row, revokedAt: new Date() }, + { ...row, providerId: 'google-service-account' }, + { ...row, encryptedServiceAccountKey: 'x'.repeat(16_385) }, + ])('rejects incompatible or oversized credential rows before decrypting', async (invalidRow) => { + queueTableRows(credential, [invalidRow]) + await expect( + resolveServiceAccountToken( + 'credential-1', + GITHUB_INSTALLATION_PROVIDER_ID, + undefined, + undefined, + { githubRepositoryScope: { repositoryId: '101' } } + ) + ).rejects.toThrow('unavailable') + expect(mocks.decryptSecret).not.toHaveBeenCalled() + }) + + it.each([ + { ...row, providerSubjectId: '22' }, + { ...row, providerTenantId: '12' }, + ])( + 'refuses a credential whose stored columns disagree with the verified binding', + async (invalidRow) => { + queueTableRows(credential, [invalidRow]) + await expect( + resolveServiceAccountToken( + 'credential-1', + GITHUB_INSTALLATION_PROVIDER_ID, + undefined, + undefined, + { githubRepositoryScope: { repositoryId: '101' } } + ) + ).rejects.toThrow('does not match its binding') + expect(mocks.resolveToken).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/oauth/github-installation-types.ts b/apps/sim/lib/oauth/github-installation-types.ts new file mode 100644 index 00000000000..1aae8c76752 --- /dev/null +++ b/apps/sim/lib/oauth/github-installation-types.ts @@ -0,0 +1,23 @@ +/** Installation credentials supply repository content, never a person's access grants. */ +export const GITHUB_INSTALLATION_PROVIDER_ID = 'github-app-installation' as const + +export interface GitHubInstallationSummary { + appId: string + appClientId: string + installationId: string + accountId: string + accountType: 'User' | 'Organization' + accountLogin: string + repositorySelection: 'all' | 'selected' +} + +/** Provider-verified installation identity; the application's signing key stays server-owned. */ +export interface GitHubInstallationBinding extends GitHubInstallationSummary { + type: 'github_app_installation' + version: 1 +} + +export interface GitHubInstallationRepositoryScope { + repositoryId?: string + repository?: string +} diff --git a/apps/sim/lib/oauth/github-installation.test.ts b/apps/sim/lib/oauth/github-installation.test.ts new file mode 100644 index 00000000000..4267804d545 --- /dev/null +++ b/apps/sim/lib/oauth/github-installation.test.ts @@ -0,0 +1,331 @@ +/** @vitest-environment node */ +import { generateKeyPairSync, verify } from 'node:crypto' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + assertGitHubInstallationActive, + assertGitHubInstallationRepositoryActive, + getGitHubInstallationConfiguration, + listUserAdminGitHubInstallations, + parseGitHubInstallationBinding, + resolveGitHubInstallationAccessToken, + resolveGitHubInstallationRepository, + verifyGitHubInstallationBinding, +} from '@/lib/oauth/github-installation' +import type { GitHubInstallationBinding } from '@/lib/oauth/github-installation-types' + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() +const user = { id: 9, type: 'User' } +const installation = { + id: 21, + app_id: 1, + client_id: 'Iv123', + account: { id: 11, login: 'team', type: 'Organization' }, + repository_selection: 'selected', + permissions: { contents: 'read', metadata: 'read' }, + suspended_at: null, +} +const membership = { state: 'active', role: 'admin', organization: { id: 11 }, user: { id: 9 } } +const binding: GitHubInstallationBinding = { + type: 'github_app_installation', + version: 1, + appId: '1', + appClientId: 'Iv123', + installationId: '21', + accountId: '11', + accountType: 'Organization', + accountLogin: 'team', + repositorySelection: 'selected', +} +let now = Date.UTC(2026, 8, 9) +const fetchMock = vi.fn() + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { status }) +} + +function tokenResponse(repositoryId = 101, contents = true) { + return { + token: contents ? 'ghs_contents' : 'ghs_metadata', + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + permissions: { metadata: 'read', ...(contents ? { contents: 'read' } : {}) }, + repositories: [{ id: repositoryId }], + } +} + +beforeEach(() => { + vi.useFakeTimers() + now += 86_400_000 + vi.setSystemTime(now) + setEnv({ + GITHUB_APP_ID: '1', + GITHUB_APP_CLIENT_ID: 'Iv123', + GITHUB_APP_CLIENT_SECRET: 'secret', + GITHUB_APP_PRIVATE_KEY: privateKeyPem, + GITHUB_APP_SLUG: 'sim-search', + }) + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + resetEnvMock() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +function mockDiscovery( + installs: unknown[] = [installation], + memberships: unknown[] = [membership] +) { + fetchMock.mockImplementation(async (input) => { + const path = new URL(String(input)).pathname + if (path === '/user') return json(user) + if (path === '/user/memberships/orgs') return json(memberships) + if (path === '/user/installations') + return json({ total_count: installs.length, installations: installs }) + if (path === '/app/installations/21') return json(installation) + throw new Error(`Unexpected request: ${path}`) + }) +} + +describe('GitHub installation setup', () => { + it('rechecks repository installation selection without caching a previous success', async () => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json({ message: 'Not Found' }, 404)) + await expect( + assertGitHubInstallationRepositoryActive(binding, 'team/repo') + ).resolves.toBeUndefined() + await expect(assertGitHubInstallationRepositoryActive(binding, 'team/repo')).rejects.toThrow() + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://api.github.com/repos/team/repo/installation', + 'https://api.github.com/repos/team/repo/installation', + ]) + const headers = new Headers(fetchMock.mock.calls[0][1]?.headers) + expect(headers.get('authorization')?.split('.')).toHaveLength(3) + }) + + it.each([ + { ...installation, id: 22 }, + { ...installation, app_id: 2 }, + { ...installation, client_id: 'another-app' }, + { ...installation, account: { ...installation.account, id: 12 } }, + { ...installation, suspended_at: '2026-01-01T00:00:00Z' }, + { ...installation, permissions: { metadata: 'read' } }, + ])('rejects a removed, suspended, or rebound repository installation %#', async (current) => { + fetchMock.mockResolvedValueOnce(json(current)) + await expect(assertGitHubInstallationRepositoryActive(binding, 'team/repo')).rejects.toThrow( + 'unavailable or its account binding changed' + ) + }) + + it('requires a valid RSA app key and returns no credentials in readiness metadata', () => { + expect(getGitHubInstallationConfiguration()).toEqual({ + configured: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + }) + setEnv({ GITHUB_APP_PRIVATE_KEY: 'invalid' }) + expect(getGitHubInstallationConfiguration()).toEqual({ configured: false, installUrl: null }) + }) + + it('lists only owned personal accounts and active organization-owner installations', async () => { + mockDiscovery([ + installation, + { ...installation, id: 22, account: { id: 9, login: 'me', type: 'User' } }, + { ...installation, id: 23, account: { id: 10, login: 'other', type: 'User' } }, + { + ...installation, + id: 24, + account: { id: 12, login: 'read-only-org', type: 'Organization' }, + }, + { ...installation, id: 25, suspended_at: '2026-01-01T00:00:00Z' }, + { ...installation, id: 26, app_id: 2 }, + ]) + expect( + (await listUserAdminGitHubInstallations('ghu_user')).map((entry) => entry.installationId) + ).toEqual(['21', '22']) + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/app/installations'))).toBe( + false + ) + }) + + it('never treats read-visible installation membership as authority to bind', async () => { + mockDiscovery([installation], [{ ...membership, role: 'member' }]) + await expect(verifyGitHubInstallationBinding('ghu_user', '21')).rejects.toThrow( + 'Only the GitHub account owner' + ) + }) + + it('rejects pending owners and provider identity mismatches', async () => { + mockDiscovery([installation], [{ ...membership, state: 'pending' }]) + expect(await listUserAdminGitHubInstallations('ghu_user')).toEqual([]) + mockDiscovery([installation], [{ ...membership, user: { id: 99 } }]) + await expect(listUserAdminGitHubInstallations('ghu_user')).rejects.toThrow( + 'identity does not match' + ) + }) + + it('revalidates the chosen installation using a short-lived signed app JWT', async () => { + mockDiscovery() + expect(await verifyGitHubInstallationBinding('ghu_user', '21')).toEqual(binding) + const appRequest = fetchMock.mock.calls.find(([url]) => + String(url).endsWith('/app/installations/21') + ) + const token = new Headers(appRequest?.[1]?.headers).get('Authorization')?.slice(7) ?? '' + const [header, payload, signature] = token.split('.') + expect( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url') + ) + ).toBe(true) + expect(JSON.parse(Buffer.from(payload, 'base64url').toString())).toEqual({ + iat: now / 1000 - 60, + exp: now / 1000 + 540, + iss: 'Iv123', + }) + expect(appRequest?.[1]?.redirect).toBe('error') + }) + + it('accepts GitHub responses omitting optional client_id, using verified app_id', async () => { + const { client_id: _clientId, ...withoutClientId } = installation + mockDiscovery([withoutClientId]) + expect((await listUserAdminGitHubInstallations('ghu_user'))[0].appClientId).toBe('Iv123') + }) + + it('fails closed when a listing reaches the explicit page cap', async () => { + fetchMock.mockImplementation(async (input) => { + const path = new URL(String(input)).pathname + return path === '/user' ? json(user) : json(Array.from({ length: 100 }, () => membership)) + }) + await expect(listUserAdminGitHubInstallations('ghu_user')).rejects.toThrow('listing exceeds') + expect(fetchMock).toHaveBeenCalledTimes(11) + }) + + it('rejects unsafe identifiers, unknown binding fields, and non-user tokens before network access', async () => { + expect(() => parseGitHubInstallationBinding({ ...binding, privateKey: 'untrusted' })).toThrow( + 'invalid' + ) + await expect(verifyGitHubInstallationBinding('ghu_user', '../21')).rejects.toThrow( + 'ID is invalid' + ) + await expect(listUserAdminGitHubInstallations('ghs_installation')).rejects.toThrow( + 'Connect your GitHub account' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('GitHub installation content tokens', () => { + it.each([ + { ...installation, suspended_at: '2026-01-01T00:00:00Z' }, + { ...installation, account: { ...installation.account, id: 99 } }, + { ...installation, app_id: 2 }, + { ...installation, client_id: 'wrong' }, + { ...installation, permissions: { metadata: 'read' } }, + ])('denies a suspended, moved, or incompatible installation', async (providerInstallation) => { + fetchMock.mockResolvedValue(json(providerInstallation)) + await expect(assertGitHubInstallationActive(binding)).rejects.toThrow( + 'unavailable or its account binding changed' + ) + }) + + it('refuses cached bindings after changing the configured app', async () => { + setEnv({ GITHUB_APP_ID: '2' }) + await expect(assertGitHubInstallationActive(binding)).rejects.toThrow( + 'different configured app' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('mints a token narrowed to one immutable repository with only read permissions', async () => { + fetchMock.mockResolvedValueOnce(json(installation)).mockResolvedValueOnce(json(tokenResponse())) + expect(await resolveGitHubInstallationAccessToken(binding, { repositoryId: '101' })).toEqual({ + accessToken: 'ghs_contents', + }) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + permissions: { contents: 'read', metadata: 'read' }, + repository_ids: [101], + }) + }) + + it('rechecks suspension before returning a cached token', async () => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json(tokenResponse())) + .mockResolvedValueOnce(json({ ...installation, suspended_at: '2026-01-01T00:00:00Z' })) + await resolveGitHubInstallationAccessToken(binding, { repositoryId: '101' }) + await expect( + resolveGitHubInstallationAccessToken(binding, { repositoryId: '101' }) + ).rejects.toThrow('unavailable') + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('refuses unscoped token resolution and widened provider token responses', async () => { + await expect(resolveGitHubInstallationAccessToken(binding, {})).rejects.toThrow( + 'require a source repository' + ) + expect(fetchMock).not.toHaveBeenCalled() + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce( + json({ ...tokenResponse(), permissions: { contents: 'write', metadata: 'read' } }) + ) + await expect( + resolveGitHubInstallationAccessToken(binding, { repositoryId: '101' }) + ).rejects.toThrow('invalid installation token scope') + }) + + it.each(['team/repo', ' https://github.com/team/repo.git/ '])( + 'resolves %s using repository-scoped metadata access and verifies its owner ID', + async (repository) => { + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce(json(tokenResponse(101, false))) + .mockResolvedValueOnce( + json({ id: 101, full_name: 'team/repo', owner: { id: 11 }, default_branch: 'main' }) + ) + expect(await resolveGitHubInstallationRepository(binding, repository)).toEqual({ + id: '101', + fullName: 'team/repo', + defaultBranch: 'main', + }) + expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ + permissions: { metadata: 'read' }, + repositories: ['repo'], + }) + expect(fetchMock.mock.calls[2][0]).toBe('https://api.github.com/repos/team/repo') + fetchMock + .mockResolvedValueOnce(json(installation)) + .mockResolvedValueOnce( + json({ id: 101, full_name: 'team/repo', owner: { id: 99 }, default_branch: 'main' }) + ) + await expect(resolveGitHubInstallationRepository(binding, 'team/repo')).rejects.toThrow( + 'another GitHub installation account' + ) + } + ) + + it.each([ + 'https://github.com@evil.example/team/repo', + 'https://github.com.evil.example/team/repo', + 'team/../repo', + 'team/repo?redirect=https://example.com', + ])('rejects unsafe repository %s before minting a token', async (repository) => { + await expect(resolveGitHubInstallationRepository(binding, repository)).rejects.toThrow( + 'owner/repo format' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects oversized provider payloads before parsing', async () => { + fetchMock.mockResolvedValue( + new Response('x', { headers: { 'content-length': String(3 * 1024 * 1024) } }) + ) + await expect(assertGitHubInstallationActive(binding)).rejects.toThrow() + }) +}) diff --git a/apps/sim/lib/oauth/github-installation.ts b/apps/sim/lib/oauth/github-installation.ts new file mode 100644 index 00000000000..1e3c7dfe720 --- /dev/null +++ b/apps/sim/lib/oauth/github-installation.ts @@ -0,0 +1,481 @@ +import { createHash, createPrivateKey, createSign } from 'node:crypto' +import { z } from 'zod' +import { env } from '@/lib/core/config/env' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + GitHubInstallationBinding, + GitHubInstallationRepositoryScope, + GitHubInstallationSummary, +} from '@/lib/oauth/github-installation-types' +import { parseGitHubRepository } from '@/lib/oauth/github-repository' + +const API_URL = 'https://api.github.com' +const PAGE_SIZE = 100 +const MAX_PAGES = 10 +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +const REQUEST_TIMEOUT_MS = 10_000 +const OPERATION_TIMEOUT_MS = 30_000 +const TOKEN_HEADROOM_MS = 5 * 60_000 +const MAX_CACHED_TOKENS = 128 + +const idSchema = z + .string() + .regex(/^[1-9]\d{0,15}$/) + .refine((id) => Number.isSafeInteger(Number(id))) +const apiIdSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const loginSchema = z.string().regex(/^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i) +const permissionsSchema = z.object({ + contents: z.enum(['read', 'write']).optional(), + metadata: z.literal('read').optional(), +}) +const installationSchema = z.object({ + id: apiIdSchema, + app_id: apiIdSchema, + client_id: z.string().min(1).max(200).optional(), + account: z.object({ + id: apiIdSchema, + login: loginSchema, + type: z.enum(['User', 'Organization']), + }), + repository_selection: z.enum(['all', 'selected']), + permissions: permissionsSchema, + suspended_at: z.string().nullable(), +}) +const bindingSchema = z + .object({ + type: z.literal('github_app_installation'), + version: z.literal(1), + appId: idSchema, + appClientId: z.string().min(1).max(200), + installationId: idSchema, + accountId: idSchema, + accountType: z.enum(['User', 'Organization']), + accountLogin: loginSchema, + repositorySelection: z.enum(['all', 'selected']), + }) + .strict() +const repositorySchema = z.object({ + id: apiIdSchema, + full_name: z.string().min(1).max(200), + owner: z.object({ id: apiIdSchema }), + default_branch: z.string().min(1).max(1024), +}) + +export class GitHubInstallationError extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message) + this.name = 'GitHubInstallationError' + } +} + +function readConfiguration() { + const appId = env.GITHUB_APP_ID?.trim() + const clientId = env.GITHUB_APP_CLIENT_ID?.trim() + const clientSecretConfigured = Boolean(env.GITHUB_APP_CLIENT_SECRET?.trim()) + const privateKey = env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, '\n').trim() + const slug = env.GITHUB_APP_SLUG?.trim() + if ( + !appId || + !idSchema.safeParse(appId).success || + !clientId || + !clientSecretConfigured || + !privateKey || + !slug || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) + ) + return null + try { + const key = createPrivateKey(privateKey) + if (key.asymmetricKeyType !== 'rsa') return null + return { + appId, + clientId, + key, + slug, + keyRevision: createHash('sha256').update(privateKey).digest('hex'), + } + } catch { + return null + } +} + +/** Exposes readiness and the provider installation URL without returning signing material. */ +export function getGitHubInstallationConfiguration() { + const configuration = readConfiguration() + return { + configured: configuration !== null, + installUrl: configuration + ? `https://github.com/apps/${configuration.slug}/installations/new` + : null, + } +} + +function requireConfiguration() { + const configuration = readConfiguration() + if (!configuration) + throw new GitHubInstallationError('GitHub App installation setup is not configured') + return configuration +} + +function createAppJwt(configuration: NonNullable>) { + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url') + const payload = Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: configuration.clientId }) + ).toString('base64url') + const message = `${header}.${payload}` + return `${message}.${createSign('RSA-SHA256').update(message).sign(configuration.key).toString('base64url')}` +} + +interface RequestOptions { + signal?: AbortSignal +} + +function operationSignal(options: RequestOptions): AbortSignal { + const timeout = AbortSignal.timeout(OPERATION_TIMEOUT_MS) + return options.signal ? AbortSignal.any([options.signal, timeout]) : timeout +} + +async function request( + path: string, + token: string, + signal: AbortSignal, + body?: unknown +): Promise { + const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) + const response = await fetch(`${API_URL}${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Sim', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'error', + signal: requestSignal, + }) + if (!response.ok) { + await response.body?.cancel() + throw new GitHubInstallationError( + `GitHub installation request failed with HTTP ${response.status}`, + response.status + ) + } + return readResponseJsonWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + signal: requestSignal, + label: 'GitHub installation response', + }) +} + +function summary( + installation: z.output, + clientId: string +): GitHubInstallationSummary { + return { + appId: String(installation.app_id), + appClientId: clientId, + installationId: String(installation.id), + accountId: String(installation.account.id), + accountType: installation.account.type, + accountLogin: installation.account.login, + repositorySelection: installation.repository_selection, + } +} + +function installationIsReady( + installation: z.output, + configuration: { appId: string; clientId: string } +) { + return ( + String(installation.app_id) === configuration.appId && + (installation.client_id === undefined || installation.client_id === configuration.clientId) && + installation.suspended_at === null && + Boolean(installation.permissions.contents) && + installation.permissions.metadata === 'read' + ) +} + +/** Rejects malformed encrypted blobs without interpreting an installation as a human identity. */ +export function parseGitHubInstallationBinding(value: unknown): GitHubInstallationBinding { + const parsed = bindingSchema.safeParse(value) + if (!parsed.success) + throw new GitHubInstallationError('Stored GitHub installation binding is invalid') + return parsed.data +} + +async function adminAccountIds(userAccessToken: string, signal: AbortSignal) { + if (!userAccessToken.startsWith('ghu_')) + throw new GitHubInstallationError('Connect your GitHub account before choosing an installation') + const user = z + .object({ id: apiIdSchema, type: z.literal('User') }) + .parse(await request('/user', userAccessToken, signal)) + const organizations = new Set() + const membershipsSchema = z + .array( + z.object({ + state: z.enum(['active', 'pending']), + role: z.enum(['admin', 'member', 'billing_manager']), + organization: z.object({ id: apiIdSchema }), + user: z.object({ id: apiIdSchema }), + }) + ) + .max(PAGE_SIZE) + for (let page = 1; page <= MAX_PAGES; page++) { + const memberships = membershipsSchema.parse( + await request( + `/user/memberships/orgs?state=active&per_page=${PAGE_SIZE}&page=${page}`, + userAccessToken, + signal + ) + ) + for (const membership of memberships) { + if (membership.user.id !== user.id) + throw new GitHubInstallationError( + 'GitHub membership identity does not match the connected account' + ) + if (membership.state === 'active' && membership.role === 'admin') + organizations.add(String(membership.organization.id)) + } + if (memberships.length < PAGE_SIZE) return { userId: String(user.id), organizations } + } + throw new GitHubInstallationError( + 'GitHub organization membership listing exceeds the supported limit' + ) +} + +/** User-visible installations are filtered by actual account ownership, not mere repository access. */ +export async function listUserAdminGitHubInstallations( + userAccessToken: string, + options: RequestOptions = {} +): Promise { + const configuration = requireConfiguration() + const signal = operationSignal(options) + const accounts = await adminAccountIds(userAccessToken, signal) + const result: GitHubInstallationSummary[] = [] + const pageSchema = z.object({ + total_count: z + .number() + .int() + .min(0) + .max(PAGE_SIZE * MAX_PAGES), + installations: z.array(installationSchema).max(PAGE_SIZE), + }) + for (let page = 1; page <= MAX_PAGES; page++) { + const data = pageSchema.parse( + await request( + `/user/installations?per_page=${PAGE_SIZE}&page=${page}`, + userAccessToken, + signal + ) + ) + for (const installation of data.installations) { + const ownsAccount = + installation.account.type === 'User' + ? String(installation.account.id) === accounts.userId + : accounts.organizations.has(String(installation.account.id)) + if (ownsAccount && installationIsReady(installation, configuration)) + result.push(summary(installation, configuration.clientId)) + } + if (data.installations.length < PAGE_SIZE) return result + } + throw new GitHubInstallationError('GitHub installation listing exceeds the supported limit') +} + +async function readBoundInstallation( + binding: GitHubInstallationBinding, + path: string, + options: RequestOptions = {} +) { + const verified = parseGitHubInstallationBinding(binding) + const configuration = requireConfiguration() + if (verified.appId !== configuration.appId || verified.appClientId !== configuration.clientId) + throw new GitHubInstallationError('GitHub installation belongs to a different configured app') + const installation = installationSchema.parse( + await request(path, createAppJwt(configuration), operationSignal(options)) + ) + if ( + !installationIsReady(installation, configuration) || + String(installation.id) !== verified.installationId || + String(installation.account.id) !== verified.accountId || + installation.account.type !== verified.accountType + ) + throw new GitHubInstallationError( + 'GitHub installation is unavailable or its account binding changed' + ) + return summary(installation, configuration.clientId) +} + +/** Rechecks current provider state so cached content tokens never hide app suspension or rebinding. */ +export async function assertGitHubInstallationActive( + binding: GitHubInstallationBinding, + options: RequestOptions = {} +) { + return readBoundInstallation(binding, `/app/installations/${binding.installationId}`, options) +} + +/** Rechecks the repository's current installation even when its public content remains readable. */ +export async function assertGitHubInstallationRepositoryActive( + binding: GitHubInstallationBinding, + repository: string, + options: RequestOptions = {} +): Promise { + const { owner, repo } = parseGitHubRepository(repository) + await readBoundInstallation( + binding, + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/installation`, + options + ) +} + +/** Requires both the initiating GitHub user's account authority and the server's current app identity. */ +export async function verifyGitHubInstallationBinding( + userAccessToken: string, + installationId: string, + options: RequestOptions = {} +): Promise { + if (!idSchema.safeParse(installationId).success) + throw new GitHubInstallationError('GitHub installation ID is invalid') + const signal = operationSignal(options) + const installations = await listUserAdminGitHubInstallations(userAccessToken, { signal }) + const installation = installations.find( + (candidate) => candidate.installationId === installationId + ) + if (!installation) + throw new GitHubInstallationError( + 'Only the GitHub account owner or an organization owner can connect this installation' + ) + const binding: GitHubInstallationBinding = { + type: 'github_app_installation', + version: 1, + ...installation, + } + return { + type: 'github_app_installation', + version: 1, + ...(await assertGitHubInstallationActive(binding, { signal })), + } +} + +interface CachedToken { + accessToken: string + expiresAt: number +} +const tokenCache = new Map() + +async function mintToken( + binding: GitHubInstallationBinding, + signal: AbortSignal, + repositoryId?: string, + repositoryName?: string +) { + const configuration = requireConfiguration() + const key = [ + configuration.keyRevision, + binding.appClientId, + binding.installationId, + binding.accountId, + repositoryId ?? `metadata:${repositoryName}`, + ].join(':') + for (const [cachedKey, entry] of tokenCache) + if (entry.expiresAt <= Date.now() + TOKEN_HEADROOM_MS) tokenCache.delete(cachedKey) + const cached = tokenCache.get(key) + if (cached) return cached.accessToken + const permissions = repositoryId ? { contents: 'read', metadata: 'read' } : { metadata: 'read' } + const response = z + .object({ + token: z.string().min(1).max(1024), + expires_at: z.iso.datetime(), + permissions: permissionsSchema.strict(), + repositories: z + .array(z.object({ id: apiIdSchema })) + .max(1) + .optional(), + }) + .parse( + await request( + `/app/installations/${binding.installationId}/access_tokens`, + createAppJwt(configuration), + signal, + { + permissions, + ...(repositoryId + ? { repository_ids: [Number(repositoryId)] } + : { repositories: [repositoryName] }), + } + ) + ) + const expiresAt = Date.parse(response.expires_at) + if ( + expiresAt <= Date.now() + TOKEN_HEADROOM_MS || + expiresAt > Date.now() + 65 * 60_000 || + response.permissions.metadata !== 'read' || + response.permissions.contents !== (repositoryId ? 'read' : undefined) || + !response.repositories || + response.repositories.length !== 1 || + (repositoryId && String(response.repositories[0].id) !== repositoryId) + ) { + throw new GitHubInstallationError( + 'GitHub returned an invalid installation token scope or expiration' + ) + } + if (tokenCache.size >= MAX_CACHED_TOKENS) { + const oldest = tokenCache.keys().next().value + if (oldest !== undefined) tokenCache.delete(oldest) + } + tokenCache.set(key, { accessToken: response.token, expiresAt }) + return response.token +} + +/** Resolves a mutable repository name to its immutable identity inside the bound GitHub account. */ +export async function resolveGitHubInstallationRepository( + binding: GitHubInstallationBinding, + repository: string, + options: RequestOptions = {} +) { + let parsed: ReturnType + try { + parsed = parseGitHubRepository(repository) + } catch { + throw new GitHubInstallationError('Use a GitHub repository in owner/repo format') + } + const { owner, repo } = parsed + const signal = operationSignal(options) + await assertGitHubInstallationActive(binding, { signal }) + const token = await mintToken(binding, signal, undefined, repo) + const resolved = repositorySchema.parse( + await request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, token, signal) + ) + if (String(resolved.owner.id) !== binding.accountId) + throw new GitHubInstallationError('Repository belongs to another GitHub installation account') + return { + id: String(resolved.id), + fullName: resolved.full_name, + defaultBranch: resolved.default_branch, + } +} + +/** Mints contents access for exactly one source repository; generic unscoped token reads are refused. */ +export async function resolveGitHubInstallationAccessToken( + binding: GitHubInstallationBinding, + scope: GitHubInstallationRepositoryScope, + options: RequestOptions = {} +) { + const signal = operationSignal(options) + let repositoryId = scope.repositoryId + if (repositoryId && !idSchema.safeParse(repositoryId).success) + throw new GitHubInstallationError('GitHub repository ID is invalid') + if (!repositoryId && scope.repository) + repositoryId = ( + await resolveGitHubInstallationRepository(binding, scope.repository, { signal }) + ).id + if (!repositoryId) + throw new GitHubInstallationError('GitHub installation tokens require a source repository') + await assertGitHubInstallationActive(binding, { signal }) + return { accessToken: await mintToken(binding, signal, repositoryId) } +} diff --git a/apps/sim/lib/oauth/github-repository.test.ts b/apps/sim/lib/oauth/github-repository.test.ts new file mode 100644 index 00000000000..2dfb2a25d01 --- /dev/null +++ b/apps/sim/lib/oauth/github-repository.test.ts @@ -0,0 +1,42 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { parseGitHubRepository } from '@/lib/oauth/github-repository' + +describe('parseGitHubRepository', () => { + it.each([ + 'owner/repo', + ' owner/repo ', + 'owner/repo.git', + 'owner/repo.git/', + 'https://github.com/owner/repo', + 'https://github.com/owner/repo.git/', + 'HTTP://GITHUB.COM/owner/repo/', + ])('normalizes %s to the same repository', (repository) => { + expect(parseGitHubRepository(repository)).toEqual({ owner: 'owner', repo: 'repo' }) + }) + + it.each([ + '', + 'owner', + 'owner/repo/extra', + 'owner/.', + 'owner/..', + 'owner/../repo', + 'owner/%2e%2e', + 'owner/repo%2fextra', + 'owner/repo?redirect=https://example.com', + 'owner/repo#fragment', + 'owner\\repo', + '//github.com/owner/repo', + 'https://github.com.evil.example/owner/repo', + 'https://github.com@evil.example/owner/repo', + 'https://evil.example@github.com/owner/repo', + 'https://github.com:443/owner/repo', + 'https://127.0.0.1/owner/repo', + 'https://github.com/owner/repo/../../app', + 'https://github.com/owner/repo.git//', + 'git@github.com:owner/repo.git', + ])('rejects invalid or unsafe repository reference %s', (repository) => { + expect(() => parseGitHubRepository(repository)).toThrow('Invalid repository format') + }) +}) diff --git a/apps/sim/lib/oauth/github-repository.ts b/apps/sim/lib/oauth/github-repository.ts new file mode 100644 index 00000000000..087f5858470 --- /dev/null +++ b/apps/sim/lib/oauth/github-repository.ts @@ -0,0 +1,19 @@ +/** Accepts GitHub repository names and web URLs without allowing provider-path traversal. */ +export function parseGitHubRepository(repository: string): { owner: string; repo: string } { + const cleaned = repository + .trim() + .replace(/^https?:\/\/github\.com\//i, '') + .replace(/\/$/, '') + .replace(/\.git$/, '') + const parts = cleaned.split('/') + if ( + parts.length !== 2 || + !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(parts[0] ?? '') || + !/^[a-z\d_.-]+$/i.test(parts[1] ?? '') || + parts[1] === '.' || + parts[1] === '..' + ) { + throw new Error(`Invalid repository format: "${repository}". Use "owner/repo".`) + } + return { owner: parts[0], repo: parts[1] } +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5e1dda3ce1e..1d6891b9d6c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -80,6 +80,7 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { GITHUB_TOKEN_URL, parseGitHubRepositoriesTokenResponse, @@ -119,6 +120,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'GitHub', description: 'Search repository files through your GitHub App access.', providerId: 'github-repositories', + serviceAccountProviderId: GITHUB_INSTALLATION_PROVIDER_ID, icon: GithubIcon, baseProviderIcon: GithubIcon, scopes: [], diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index c5367f11976..bf2e2980b4d 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -402,16 +402,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2242, + "modules": 2291, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 676, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 707, "apps/sim/triggers/registry.ts": 485, + "apps/sim/lib/auth/index.ts": 365, "apps/sim/blocks/registry.ts": 354, - "apps/sim/lib/auth/index.ts": 351, - "apps/sim/lib/webhooks/providers/index.ts": 117, - "apps/sim/lib/webhooks/providers/registry.ts": 115, - "apps/sim/ee/access-control/components/access-control.tsx": 74, - "apps/sim/ee/access-control/components/group-detail.tsx": 72 + "apps/sim/lib/webhooks/providers/index.ts": 118, + "apps/sim/lib/webhooks/providers/registry.ts": 116, + "apps/sim/ee/access-control/components/access-control.tsx": 75, + "apps/sim/ee/access-control/components/group-detail.tsx": 73 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { From 768c3897250ed49c18a30b5c927f335ad02243d6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 16:55:59 -0700 Subject: [PATCH 06/30] fix(billing): scope subscription limit syncs to the exact payer (#7695) * fix(billing): scope subscription limit syncs to the exact payer * fix(billing): retry subscription limit reconciliation through webhook events * chore(tests): remove timing-dependent search setup case --- .../components/search-source-setup.test.tsx | 41 ----- apps/sim/lib/auth/auth.ts | 12 +- .../sim/lib/billing/core/subscription.test.ts | 48 ++++-- apps/sim/lib/billing/core/subscription.ts | 21 +-- apps/sim/lib/billing/core/usage.test.ts | 57 ++++++- apps/sim/lib/billing/core/usage.ts | 38 ++--- apps/sim/lib/billing/organization.test.ts | 51 +++++- apps/sim/lib/billing/organization.ts | 45 +----- .../webhooks/subscription-usage.test.ts | 151 ++++++++++++++++++ .../billing/webhooks/subscription-usage.ts | 30 ++++ 10 files changed, 332 insertions(+), 162 deletions(-) create mode 100644 apps/sim/lib/billing/webhooks/subscription-usage.test.ts create mode 100644 apps/sim/lib/billing/webhooks/subscription-usage.ts diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx index 838cb7aec2f..1edc21572e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx @@ -413,47 +413,6 @@ describe('organization setup entry points', () => { expect(mocks.push).toHaveBeenCalledWith('/o/org-1/settings/integrations/sources/new-source') }) - it('honors explicit member-source URLs and clears both setup parameters on close', async () => { - useConnectorSetupStore - .getState() - .saveDraft('user-1:organization:org-1:kb-search:github:members', { - sourceConfig: { repository: 'acme/docs' }, - canonicalModes: {}, - accessMode: 'members', - credentialId: 'cred-source', - contentCredentialId: null, - disabledTagIds: [], - savedAt: Date.now(), - }) - await render(organizationSetup(), '?addConnector=github&source-access=members&search=keep') - - expect(mocks.replace).not.toHaveBeenCalled() - expect(document.querySelector('button[aria-label="Choose another source"]')).toBeNull() - expect(document.body.textContent).not.toContain('Sync using') - expect(document.body.textContent).toContain('Sync documents with') - expect(button('Add source')).toBeEnabled() - await click(button('Add source')) - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - connectorType: 'github', - accessMode: 'members', - sourceConfig: { repository: 'acme/docs' }, - }), - expect.any(Object) - ) - await click(button('Cancel')) - expect(mocks.urlUpdate).toHaveBeenLastCalledWith( - expect.objectContaining({ queryString: '?search=keep' }) - ) - expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(mocks.push).not.toHaveBeenCalled() - expect( - useConnectorSetupStore - .getState() - .getDraft('user-1:organization:org-1:kb-search:github:members') - ).toBeUndefined() - }) - it.each(['github', 'gmail', 'google_calendar', 'jira'])( 'returns old %s organization setup links to personal integrations without loading the index', async (type) => { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3cb2ee6b406..ace5b81ed3a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -101,6 +101,7 @@ import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' +import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage' import { env } from '@/lib/core/config/env' import { isAuthDisabled, @@ -1615,16 +1616,6 @@ export const auth = betterAuth({ throw orgError } - try { - await syncSubscriptionUsageLimits(resolvedSubscription) - } catch (error) { - logger.error('[onSubscriptionUpdate] Failed to sync usage limits', { - subscriptionId: resolvedSubscription.id, - referenceId: resolvedSubscription.referenceId, - error, - }) - } - if (isTeam(effectivePlanForTeamFeatures)) { try { const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1 @@ -1703,6 +1694,7 @@ export const auth = betterAuth({ case 'customer.subscription.created': case 'customer.subscription.updated': { await handleManualEnterpriseSubscription(event) + await handleSubscriptionUsageUpdate(event) break } case 'checkout.session.expired': { diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index b61fbbf72ff..dbf656a9fef 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -1,8 +1,15 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetHighestPrioritySubscription, @@ -190,20 +197,39 @@ describe('getOrganizationCoverageForMember', () => { describe('getOrganizationIdForSubscriptionReference', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) - it('returns an organization id directly when the reference already points to one', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + afterEach(resetDbChainMock) - await expect(getOrganizationIdForSubscriptionReference('org-1')).resolves.toBe('org-1') - }) + it.each(['org-1', 'legacy-organization-id'])( + 'returns the directly referenced organization %s', + async (organizationId) => { + queueTableRows(schemaMock.organization, [{ id: organizationId }]) + + await expect(getOrganizationIdForSubscriptionReference(organizationId)).resolves.toBe( + organizationId + ) + } + ) + + it.each(['owner', 'admin', 'member'])( + 'keeps a personal subscription personal when its user is an organization %s', + async (role) => { + queueTableRows(schemaMock.organization, []) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role }]) - it('falls back to the admin-owned organization when the reference is still user-scoped', async () => { - dbChainMockFns.limit - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ organizationId: 'org-1', role: 'owner' }]) + await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBeNull() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + } + ) - await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBe('org-1') + it('propagates lookup errors instead of treating the subscription as personal', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable')) + + await expect(getOrganizationIdForSubscriptionReference('org-1')).rejects.toThrow( + 'db unavailable' + ) }) }) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index cea5d8a10ef..9b5c3717a92 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -2,7 +2,6 @@ import { cache } from 'react' import { db } from '@sim/db' import { member, organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, sql } from 'drizzle-orm' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { @@ -275,6 +274,7 @@ export async function getOrganizationCoverageForMember( } } +/** Resolves the subscription's exact organization reference without inferring ownership from membership. */ export async function getOrganizationIdForSubscriptionReference( referenceId: string ): Promise { @@ -284,24 +284,7 @@ export async function getOrganizationIdForSubscriptionReference( .where(eq(organization.id, referenceId)) .limit(1) - if (referencedOrganization) { - return referencedOrganization.id - } - - const [memberRecord] = await db - .select({ - organizationId: member.organizationId, - role: member.role, - }) - .from(member) - .where(eq(member.userId, referenceId)) - .limit(1) - - if (memberRecord && isOrgAdminRole(memberRecord.role)) { - return memberRecord.organizationId - } - - return null + return referencedOrganization?.id ?? null } /** diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index de7c5b5725e..7ce58ed8214 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -1,10 +1,9 @@ /** * Tests for getUserUsageLimit. * - * Org-scoped members carry a null `currentUsageLimit` by design, so a user - * whose subscription stops being org-scoped without a resync is left null. - * The limit read must self-heal that state to the plan/free base plus prepaid - * balance instead of failing closed and blocking every execution. + * Legacy membership syncs may leave a null personal usage limit. The limit + * read must recover the plan/free base plus prepaid balance, and subsequent + * subscription syncs must preserve independent personal and organization pools. * * @vitest-environment node */ @@ -25,12 +24,14 @@ afterAll(() => { const { mockGetFreeTierLimit, mockGetHighestPrioritySubscription, + mockGetHighestPriorityPersonalSubscription, mockGetPerUserMinimumLimit, mockHasPaidSubscriptionStatus, mockIsOrgScopedSubscription, } = vi.hoisted(() => ({ mockGetFreeTierLimit: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), + mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetPerUserMinimumLimit: vi.fn(), mockHasPaidSubscriptionStatus: vi.fn(), mockIsOrgScopedSubscription: vi.fn(), @@ -48,6 +49,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, + getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription, })) vi.mock('@/lib/billing/core/access', () => ({ @@ -205,10 +207,49 @@ describe('syncUsageLimitsFromSubscription', () => { vi.clearAllMocks() resetDbChainMock() mockIsOrgScopedSubscription.mockReturnValue(false) + mockHasPaidSubscriptionStatus.mockImplementation((status: string) => status === 'active') + }) + + it.each([ + { plan: 'pro', minimum: 40 }, + { plan: 'enterprise', minimum: 0 }, + ])( + 'preserves a personal $plan cap when the user also belongs to an enterprise organization', + async ({ plan, minimum }) => { + const personalSubscription = { plan, referenceId: 'user-1', status: 'active' } + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(personalSubscription) + mockGetHighestPrioritySubscription.mockResolvedValue({ + plan: 'enterprise', + referenceId: 'org-1', + status: 'active', + }) + mockIsOrgScopedSubscription.mockReturnValue(true) + mockGetPerUserMinimumLimit.mockReturnValue(minimum) + dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80', creditBalance: '1' }]) + + await syncUsageLimitsFromSubscription('user-1') + + expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith('user-1', { + onError: 'throw', + }) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription) + const update = dbChainMockFns.set.mock.calls[0]?.[0] + expect(update?.currentUsageLimit).not.toBeNull() + expect(JSON.stringify(update?.currentUsageLimit)).toContain('greatest') + } + ) + + it('does not reset a personal cap when its subscription lookup fails', async () => { + mockGetHighestPriorityPersonalSubscription.mockRejectedValueOnce(new Error('db unavailable')) + dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80' }]) + + await expect(syncUsageLimitsFromSubscription('user-1')).rejects.toThrow('db unavailable') + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('raises a paid personal limit to plan base plus the exact prepaid balance', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION) mockGetPerUserMinimumLimit.mockReturnValue(40) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: '40', creditBalance: '0.005' }, @@ -224,7 +265,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('restores free-tier base plus prepaid after a downgrade or org departure', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null) mockGetPerUserMinimumLimit.mockReturnValue(10) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: null, creditBalance: '0.006' }, @@ -240,7 +281,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('does not retain a higher paid custom cap after downgrade to free', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null) mockGetPerUserMinimumLimit.mockReturnValue(10) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: '100', creditBalance: '0.006' }, @@ -256,7 +297,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('preserves a higher custom personal limit', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION) mockGetPerUserMinimumLimit.mockReturnValue(40) dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '50', creditBalance: '1' }]) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 777b54586f9..7038083ec79 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -7,6 +7,7 @@ import { and, eq, isNull, sql } from 'drizzle-orm' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { + getHighestPriorityPersonalSubscription, getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' @@ -449,13 +450,11 @@ export async function updateUserUsageLimit( * checks). Org-scoped subs return the organization limit; * personally-scoped subs return the individual user limit from userStats. * - * Org-scoped members carry a null `currentUsageLimit` by design (see - * `syncUsageLimitsFromSubscription`). A user whose subscription stops being - * org-scoped without a resync would otherwise stay null and fail closed on - * every execution, so a null limit self-heals to the plan/free base plus the - * exact prepaid balance here. The write-back is best-effort: a limit written - * concurrently wins, and a failed write still resolves to the fallback - * instead of blocking execution. + * Legacy organization membership syncs may have cleared the personal limit. + * A null limit self-heals to the personal plan/free base plus the exact prepaid + * balance here. The write-back is best-effort: a limit written concurrently + * wins, and a failed write still resolves to the fallback instead of blocking + * execution. */ export async function getUserUsageLimit( userId: string, @@ -576,11 +575,12 @@ export async function checkUsageStatus(userId: string): Promise<{ } /** - * Sync usage limits based on subscription changes + * Syncs the user's personal billing pool from their exact personal subscription. + * Organization subscriptions have a separate pool and never clear personal limits. */ export async function syncUsageLimitsFromSubscription(userId: string): Promise { const [subscription, currentUserStats] = await Promise.all([ - getHighestPrioritySubscription(userId), + getHighestPriorityPersonalSubscription(userId, { onError: 'throw' }), db.select(userStatsColumns).from(userStats).where(eq(userStats.userId, userId)).limit(1), ]) @@ -588,25 +588,6 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise ({ mockCreateOrganizationWithOwner: vi.fn(), mockGetPlanPricing: vi.fn(), @@ -22,6 +29,7 @@ const { mockAssertNoCompetingEnterpriseIssuance: vi.fn(), mockGetOrganizationIdForSubscriptionReference: vi.fn(), mockIsSubscriptionOrgScoped: vi.fn(), + mockSyncUsageLimitsFromSubscription: vi.fn(), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -34,7 +42,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ })) vi.mock('@/lib/billing/core/usage', () => ({ - syncUsageLimitsFromSubscription: vi.fn(), + syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription, })) vi.mock('@/lib/billing/plan-helpers', () => ({ @@ -217,8 +225,47 @@ describe('syncSubscriptionUsageLimits', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockGetOrganizationIdForSubscriptionReference.mockResolvedValue(null) + }) + + it('syncs only the directly referenced personal subscriber', async () => { + queueTableRows(schemaMock.user, [{ id: 'user-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'other-member' }]) + + await syncSubscriptionUsageLimits({ + id: 'sub-personal', + plan: 'pro_25000', + referenceId: 'user-1', + status: 'active', + }) + + expect(mockGetOrganizationIdForSubscriptionReference).toHaveBeenCalledWith('user-1') + expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledExactlyOnceWith('user-1') + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) + it.each(['team_6000', 'enterprise'])( + 'preserves personal member limits when syncing an organization %s subscription', + async (plan) => { + mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('org-1') + mockGetPlanPricing.mockReturnValue({ basePrice: 25 }) + queueTableRows(schemaMock.member, [{ userId: 'member-1' }, { userId: 'member-2' }]) + + await syncSubscriptionUsageLimits({ + id: 'sub-organization', + plan, + referenceId: 'org-1', + status: 'active', + seats: 2, + }) + + expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.userStats) + } + ) + it('keeps prepaid headroom additive when a Team seat increase raises the base', async () => { mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('org-1') mockGetPlanPricing.mockReturnValue({ basePrice: 25 }) diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index 6c51b8f9ec4..fc6219b250c 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -488,8 +488,8 @@ export async function ensureOrganizationForTeamSubscriptionTx( } /** - * Sync usage limits for subscription members - * Updates usage limits for all users associated with the subscription + * Syncs the billing pool directly referenced by the subscription. + * Organization membership does not select or reset a personal billing pool. */ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) { try { @@ -514,7 +514,6 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData ) } - // Individual user subscription - sync their usage limits await syncUsageLimitsFromSubscription(subscription.referenceId) logger.info('Synced usage limits for individual user subscription', { @@ -523,11 +522,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData plan: subscription.plan, }) } else { - // Organization subscription - set org usage limit and sync member limits - // Set orgUsageLimit for any paid non-enterprise plan attached to - // the org. Enterprise is set via webhook with custom pricing. - // Min = (basePrice × seats) + prepaid balance. Prepaid credits are - // additive headroom and must not be absorbed by a later seat increase. + /** Enterprise has custom pricing; other paid pools retain prepaid headroom when seats increase. */ if (isPaid(subscription.plan) && !isEnterprise(subscription.plan)) { const { basePrice } = getPlanPricing(subscription.plan) const seats = subscription.seats || 1 @@ -554,40 +549,6 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData basePrice, }) } - - // Sync usage limits for all members - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - if (members.length > 0) { - for (const m of members) { - try { - await syncUsageLimitsFromSubscription(m.userId) - } catch (memberError) { - logger.error('Failed to sync usage limits for organization member', { - userId: m.userId, - organizationId, - subscriptionId: subscription.id, - error: memberError, - }) - } - } - - logger.info('Synced usage limits for organization members', { - organizationId, - memberCount: members.length, - subscriptionId: subscription.id, - plan: subscription.plan, - }) - - /** - * Storage is workspace-routed, not membership-routed. Workspace payer - * changes transfer the workspace's own durable byte ledger atomically; - * subscription sync must not move an account-wide user counter. - */ - } } } catch (error) { logger.error('Failed to sync subscription usage limits', { diff --git a/apps/sim/lib/billing/webhooks/subscription-usage.test.ts b/apps/sim/lib/billing/webhooks/subscription-usage.test.ts new file mode 100644 index 00000000000..38930df3a9a --- /dev/null +++ b/apps/sim/lib/billing/webhooks/subscription-usage.test.ts @@ -0,0 +1,151 @@ +/** @vitest-environment node */ +import { stripe } from '@better-auth/stripe' +import { createMockStripeEvent, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { betterAuth } from 'better-auth' +import { memoryAdapter } from 'better-auth/adapters/memory' +import Stripe from 'stripe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSyncSubscriptionUsageLimits } = vi.hoisted(() => ({ + mockSyncSubscriptionUsageLimits: vi.fn(), +})) + +vi.mock('@/lib/billing/organization', () => ({ + syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits, +})) + +import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage' + +const persistedSubscription = { + id: 'subscription-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 2, +} + +const updateEvent = () => + createMockStripeEvent('customer.subscription.updated', { + id: 'sub_stripe', + object: 'subscription', + customer: 'cus_1', + status: 'active', + cancel_at_period_end: false, + metadata: {}, + items: { + data: [ + { + id: 'si_1', + quantity: 2, + current_period_start: 1788220800, + current_period_end: 1790812800, + price: { id: 'price_team', recurring: { interval: 'month' } }, + }, + ], + }, + }) + +describe('handleSubscriptionUsageUpdate', () => { + beforeEach(() => { + resetDbChainMock() + mockSyncSubscriptionUsageLimits.mockReset().mockResolvedValue(undefined) + dbChainMockFns.limit.mockResolvedValue([persistedSubscription]) + }) + + afterEach(resetDbChainMock) + + it('uses the persisted payer reference after subscription callbacks have rehomed it', async () => { + await handleSubscriptionUsageUpdate(updateEvent()) + + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'eq', + left: schemaMock.subscription.stripeSubscriptionId, + right: 'sub_stripe', + }) + expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledExactlyOnceWith(persistedSubscription) + }) + + it('ignores other event types', async () => { + await handleSubscriptionUsageUpdate(createMockStripeEvent('customer.subscription.created', {})) + + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled() + }) + + it('ignores subscriptions that are not tracked locally', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await handleSubscriptionUsageUpdate(updateEvent()) + + expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled() + }) + + it.each(['lookup', 'reconciliation'])( + 'returns a failed webhook response on %s failure and reconciles on redelivery', + async (failure) => { + const onSubscriptionUpdate = vi.fn() + const stripeClient = new Stripe('sk_test_placeholder') + const webhookSecret = 'whsec_subscription_usage_test' + const provider = betterAuth({ + baseURL: 'https://sim.test', + secret: 'isolated-stripe-webhook-test-secret-123456789', + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + subscription: [ + { + ...persistedSubscription, + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_stripe', + }, + ], + }), + logger: { disabled: true }, + plugins: [ + stripe({ + stripeClient, + stripeWebhookSecret: webhookSecret, + subscription: { + enabled: true, + plans: [{ name: 'team', priceId: 'price_team' }], + onSubscriptionUpdate, + }, + onEvent: handleSubscriptionUsageUpdate, + }), + ], + }) + const payload = JSON.stringify(updateEvent()) + const signature = stripeClient.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret, + }) + const deliver = () => + provider.handler( + new Request('https://sim.test/api/auth/stripe/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'stripe-signature': signature }, + body: payload, + }) + ) + + if (failure === 'lookup') { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + } else { + mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('database unavailable')) + } + + const failed = await deliver() + expect(failed.ok).toBe(false) + expect(await failed.json()).toMatchObject({ code: 'STRIPE_WEBHOOK_ERROR' }) + expect(onSubscriptionUpdate).toHaveBeenCalledOnce() + + const retried = await deliver() + expect(retried.status).toBe(200) + expect(await retried.json()).toEqual({ success: true }) + expect(onSubscriptionUpdate).toHaveBeenCalledTimes(2) + expect(mockSyncSubscriptionUsageLimits).toHaveBeenLastCalledWith(persistedSubscription) + } + ) +}) diff --git a/apps/sim/lib/billing/webhooks/subscription-usage.ts b/apps/sim/lib/billing/webhooks/subscription-usage.ts new file mode 100644 index 00000000000..fd1a229a646 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/subscription-usage.ts @@ -0,0 +1,30 @@ +import { db } from '@sim/db' +import { subscription } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type Stripe from 'stripe' +import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' + +/** + * Reconciles usage limits through the Stripe plugin's retryable onEvent hook. + * Read the persisted reference after subscription callbacks may have moved it + * to an organization. Callback exceptions alone are swallowed by the plugin. + */ +export async function handleSubscriptionUsageUpdate(event: Stripe.Event): Promise { + if (event.type !== 'customer.subscription.updated') return + + const [persistedSubscription] = await db + .select({ + id: subscription.id, + referenceId: subscription.referenceId, + plan: subscription.plan, + status: subscription.status, + seats: subscription.seats, + }) + .from(subscription) + .where(eq(subscription.stripeSubscriptionId, event.data.object.id)) + .limit(1) + + if (!persistedSubscription) return + + await syncSubscriptionUsageLimits(persistedSubscription) +} From d3b2c5ed2fc122a3edcf6fa2bb35196b69b2364b Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 9 Sep 2026 17:14:21 -0700 Subject: [PATCH 07/30] fix(landing): prevent theme flashes and sharpen footer animation (#7699) * fix(landing): prevent theme flashes and sharpen footer animation * fix(landing): preserve the footer liquid morph --- .../footer-wordmark-loop.test.tsx | 67 +++++++++++++++++-- .../footer-wordmark-loop.tsx | 42 +++++++----- .../_shell/providers/theme-provider.test.tsx | 67 +++++++++++++++++++ apps/sim/lib/core/utils/theme.test.ts | 6 +- apps/sim/lib/core/utils/theme.ts | 15 +---- 5 files changed, 159 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx index a39fb279919..551079a0b80 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx @@ -15,6 +15,8 @@ const CYCLE_MS = 17_100 let pending: FrameRequestCallback[] = [] let clock = 0 +let reducedMotion = false +let onMotionPreference: (() => void) | undefined let root: Root | null = null let host: HTMLDivElement | null = null @@ -40,15 +42,23 @@ beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true pending = [] clock = 0 + reducedMotion = false + onMotionPreference = undefined const stubs = { requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb), cancelAnimationFrame: () => { pending = [] }, matchMedia: () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, + get matches() { + return reducedMotion + }, + addEventListener: (_type: string, listener: () => void) => { + onMotionPreference = listener + }, + removeEventListener: () => { + onMotionPreference = undefined + }, }), } for (const [name, value] of Object.entries(stubs)) { @@ -79,7 +89,10 @@ describe('FooterWordmarkLoop', () => { expect(html).toContain('aria-hidden="true"') expect(html).toContain('data-stage="wm" opacity="1"') expect(html).toContain('data-stage="orb" opacity="0"') - expect(html).toContain('stdDeviation="0.55"') + expect(html).toContain('stdDeviation="0"') + expect(html).toMatch(/filter="url\(#fwl-goo-/) + expect(html).toContain('values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"') + expect(html).not.toContain(' { it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => { expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(2700) expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) advanceTo(3900) expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') @@ -112,13 +127,53 @@ describe('FooterWordmarkLoop', () => { advanceTo(16000) expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(CYCLE_MS + 2700) expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') }) + it('returns to an identity filter when reduced motion is enabled mid-morph', () => { + advanceTo(2700) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) + + reducedMotion = true + act(() => onMotionPreference?.()) + + expect(pending).toHaveLength(0) + expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + }) + + it('eases the same filter to identity at both wordmark boundaries', () => { + advanceTo(1300) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1301) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1800) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) + + advanceTo(2500) + expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + + advanceTo(15199) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(15200) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(host?.querySelector('feComposite')).toBeNull() + }) + it('stops requesting frames on unmount', () => { advanceTo(500) act(() => root?.unmount()) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx index f07058d2c1a..f8d2d9c6688 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx @@ -43,11 +43,9 @@ const ORB_BEAT = 450 /** Closing hold on the wordmark before the loop wraps back to the opening hold. */ const HOLD_LOGO_END = 1700 const TAIL = 200 -/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */ +/** Blur range for the filtered portion of the morph. */ const GOO_HI = 5 const GOO_LO = 0.55 -/** Post-threshold blur, about half a device pixel at the mark's largest size. */ -const EDGE_SMOOTHING = 0.16 /** * Shapes that restart from compact when they appear and play exactly one pulse * of this many ms (just under a loop, so the dots reach the edge without @@ -356,13 +354,18 @@ interface StageNode { key: StageKey } +interface GooFilterNodes { + blur: SVGFEGaussianBlurElement + matrix: SVGFEColorMatrixElement +} + /** * Paints one frame of the choreography at `t` ms into the cycle by writing * SVG attributes directly - no React render per frame. */ function paintFrame( t: number, - blur: SVGFEGaussianBlurElement, + goo: GooFilterNodes, stages: StageNode[], anims: AnimatedNode[] ): void { @@ -411,8 +414,17 @@ function paintFrame( smooth(T_LOGO_HOLD_END, T_INTRO_END, t), 1 - smooth(T_OUTRO_START, T_OUTRO_END, t) ) - const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid) - if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation) + /** Ease the filter to identity at rest without overlaying the unfiltered shapes. */ + const strength = Math.min( + smooth(T_LOGO_HOLD_END, T_LOGO_HOLD_END + MORPH, t), + 1 - smooth(T_OUTRO_END - MORPH, T_OUTRO_END, t) + ) + const deviation = round((GOO_LO + (GOO_HI - GOO_LO) * liquid) * strength) + if (goo.blur.getAttribute('stdDeviation') !== deviation) { + goo.blur.setAttribute('stdDeviation', deviation) + } + const matrix = `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${round(1 + 39 * strength)} ${round(-19 * strength)}` + if (goo.matrix.getAttribute('values') !== matrix) goo.matrix.setAttribute('values', matrix) } interface FooterWordmarkLoopProps { @@ -454,7 +466,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const svg = svgRef.current if (!svg) return const blur = svg.querySelector('[data-goo]') - if (!blur) return + const matrix = svg.querySelector('[data-goo-matrix]') + if (!blur || !matrix) return + const goo: GooFilterNodes = { blur, matrix } const stages: StageNode[] = Array.from( svg.querySelectorAll('[data-stage]'), @@ -475,7 +489,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const tick = (now: number) => { if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP) previous = now - paintFrame(elapsed % CYCLE_MS, blur, stages, anims) + paintFrame(elapsed % CYCLE_MS, goo, stages, anims) frame = requestAnimationFrame(tick) } const play = () => { @@ -492,7 +506,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { if (reducedMotion?.matches) { pause() elapsed = 0 - paintFrame(0, blur, stages, anims) + paintFrame(0, goo, stages, anims) } else { play() } @@ -536,20 +550,16 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { height='160%' colorInterpolationFilters='sRGB' > - + {/* A steep threshold: the melt between shapes keeps its liquid merges, but every edge resolves within a pixel, so the mark stays crisp at the cycle's full blur. */} - {/* The threshold discards the rasterizer's edge coverage, so at the - resting blur the wordmark's edge fell inside a device pixel and - stair-stepped at the largest size. A sub-pixel blur after it - restores ordinary anti-aliasing without touching the melt. */} - diff --git a/apps/sim/app/_shell/providers/theme-provider.test.tsx b/apps/sim/app/_shell/providers/theme-provider.test.tsx index c3f58ce2ae7..98d37b45e51 100644 --- a/apps/sim/app/_shell/providers/theme-provider.test.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.test.tsx @@ -9,6 +9,7 @@ const { mockUsePathname } = vi.hoisted(() => ({ mockUsePathname: vi.fn() })) vi.mock('next/navigation', () => ({ usePathname: mockUsePathname })) +import { syncThemeToNextThemes } from '@/lib/core/utils/theme' import { ThemeProvider } from '@/app/_shell/providers/theme-provider' let root: Root @@ -37,6 +38,15 @@ function render(pathname: string) { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + /** The global storage mock is not a native jsdom Storage instance. */ + vi.stubGlobal( + 'StorageEvent', + class extends window.StorageEvent { + constructor(type: string, init: StorageEventInit) { + super(type, { ...init, storageArea: null }) + } + } + ) stubDarkOs() localStorage.clear() document.documentElement.className = '' @@ -74,4 +84,61 @@ describe('ThemeProvider theme stores', () => { localStorage.setItem('sim-landing-theme', 'dark') expect(render('/login')).toContain('light') }) + + it.each(['/', '/blog', '/customers/example'])( + 'keeps %s light when account settings resolve dark', + (pathname) => { + localStorage.setItem('sim-theme', 'dark') + const classes = render(pathname) + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + } + ) + + it('preserves the landing footer choice when account settings change', () => { + localStorage.setItem('sim-landing-theme', 'dark') + const classes = render('/workflows') + + act(() => syncThemeToNextThemes('light')) + + expect(classes).toContain('dark') + expect(localStorage.getItem('sim-landing-theme')).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('light') + }) + + it('preserves the forced auth theme when account settings resolve', () => { + const classes = render('/login') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + }) + + it('updates the workspace theme when account settings resolve', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('dark') + expect(classes).not.toContain('light') + expect(document.documentElement.style.colorScheme).toBe('dark') + }) + + it('resolves the workspace system theme through the active provider', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + + act(() => syncThemeToNextThemes('system')) + + expect(classes).toContain('dark') + expect(document.documentElement.style.colorScheme).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('system') + }) }) diff --git a/apps/sim/lib/core/utils/theme.test.ts b/apps/sim/lib/core/utils/theme.test.ts index 2d374e74eac..a161f1779d1 100644 --- a/apps/sim/lib/core/utils/theme.test.ts +++ b/apps/sim/lib/core/utils/theme.test.ts @@ -25,7 +25,7 @@ describe('syncThemeToNextThemes', () => { expect(add).not.toHaveBeenCalled() }) - it('repairs the document class without emitting a redundant storage event', () => { + it('leaves document classes to the active theme provider', () => { localStorage.setItem('sim-theme', 'dark') document.documentElement.classList.add('light') const dispatchEvent = vi.spyOn(window, 'dispatchEvent') @@ -33,7 +33,7 @@ describe('syncThemeToNextThemes', () => { syncThemeToNextThemes('dark') expect(dispatchEvent).not.toHaveBeenCalled() - expect(document.documentElement.classList.contains('dark')).toBe(true) - expect(document.documentElement.classList.contains('light')).toBe(false) + expect(document.documentElement.classList.contains('light')).toBe(true) + expect(document.documentElement.classList.contains('dark')).toBe(false) }) }) diff --git a/apps/sim/lib/core/utils/theme.ts b/apps/sim/lib/core/utils/theme.ts index 29a77542d6e..80b00ef882a 100644 --- a/apps/sim/lib/core/utils/theme.ts +++ b/apps/sim/lib/core/utils/theme.ts @@ -5,6 +5,8 @@ /** * Updates the theme in next-themes by dispatching a storage event. * This works by updating localStorage and notifying next-themes of the change. + * The active provider owns document classes, including forced themes and the + * landing surface's independent preference. * @param theme - The desired theme ('system', 'light', or 'dark') */ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { @@ -24,17 +26,4 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { }) ) } - - const root = document.documentElement - const appliedTheme = - theme === 'system' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - : theme - const oppositeTheme = appliedTheme === 'dark' ? 'light' : 'dark' - if (root.classList.contains(appliedTheme) && !root.classList.contains(oppositeTheme)) return - - root.classList.remove('light', 'dark') - root.classList.add(appliedTheme) } From cca39b6a6d4c646bd52c1c294729f445687b3592 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 17:24:44 -0700 Subject: [PATCH 08/30] feat(library): Best Open Source AI Agent Frameworks (#7702) Co-authored-by: Sim Pi Agent --- .../index.mdx | 133 ++++++++++++++++++ .../cover.jpg | Bin 0 -> 24146 bytes 2 files changed, 133 insertions(+) create mode 100644 apps/sim/content/library/best-open-source-ai-agent-frameworks/index.mdx create mode 100644 apps/sim/public/library/best-open-source-ai-agent-frameworks/cover.jpg diff --git a/apps/sim/content/library/best-open-source-ai-agent-frameworks/index.mdx b/apps/sim/content/library/best-open-source-ai-agent-frameworks/index.mdx new file mode 100644 index 00000000000..9b4c67397c2 --- /dev/null +++ b/apps/sim/content/library/best-open-source-ai-agent-frameworks/index.mdx @@ -0,0 +1,133 @@ +--- +slug: best-open-source-ai-agent-frameworks +title: 'Best Open Source AI Agent Frameworks' +description: 'Compare the best open source AI agent frameworks for visual workflows, stateful orchestration, multi-agent teams, RAG applications, and autonomous coding.' +date: 2026-09-10 +updated: 2026-09-10 +authors: + - andrew +readingTime: 8 +tags: [Open Source, AI Agents, Agent Frameworks, Sim] +ogImage: /library/best-open-source-ai-agent-frameworks/cover.jpg +canonical: https://www.sim.ai/library/best-open-source-ai-agent-frameworks +draft: false +faq: + - q: "Is LangGraph open source, and what license does it use?" + a: "LangGraph's core uses the MIT License. Both MIT and Sim's Apache 2.0 license permit commercial self-hosting. Paid LangSmith services remain separate from the framework." + - q: "Is CrewAI free?" + a: "CrewAI's MIT-licensed open-source framework is free to use. Sim likewise offers an open-source core, but each product uses a different building model. CrewAI AMP, model usage, and hosting can add costs." + - q: "What happened to Flowise?" + a: "Flowise stopped development on July 29, 2026, and archived its GitHub repository on August 13, 2026. Sim provides an actively maintained visual alternative. Existing Flowise users should review official Flowise Cloud notices and plan a migration if the announced service timeline affects them." + - q: "Is OpenHands a general agent builder?" + a: "OpenHands specializes in autonomous software development rather than general workflows. Sim supports a broader range of agent and business workflows. Choose OpenHands for coding tasks such as pull request review and CI fixes." + - q: "Can these tools be self-hosted commercially without restriction?" + a: "Commercial self-hosting rights are defined by each project's license. Sim uses Apache 2.0, while LangGraph, CrewAI, and OpenHands use MIT licenses that generally permit commercial self-hosting. Dify adds restrictions for commercial multi-tenant use, so reviewing its license before deployment helps you avoid an incompatible hosting model." + - q: "Which framework supports MCP?" + a: "MCP gives agents a standard interface for tools and context. Sim can deploy a workflow as an MCP server. CrewAI agents can also connect to MCP servers." +--- + +## TL;DR + +- **1. [Sim](https://docs.sim.ai/introduction)** fits readers seeking an Apache 2.0 workspace with native Tables, Files, and Knowledge Bases. You can build workflows with natural-language instructions through Mothership, then [deploy them through an API, chat, or MCP](https://docs.sim.ai/workflows/deployment). +- **2. [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** fits developers who need code-level control over stateful agent behavior. +- **3. [CrewAI](https://docs.crewai.com/v1.13.0/en/concepts/agents)** fits Python developers building role-based groups of collaborating agents. +- **4. [Dify](https://docs.dify.ai/en/self-host/use-dify/knowledge/readme)** fits teams building retrieval-augmented generation applications with deeper RAG tooling. +- **5. [Flowise](https://github.com/FlowiseAI/Flowise)** is best treated as a legacy visual builder because its GitHub repository is archived and no longer receives active development. +- **6. [OpenHands](https://github.com/All-Hands-AI/OpenHands/)** fits autonomous coding and software delivery tasks. OpenHands belongs to a different category than general-purpose agent builders. + +## What counts as an open-source AI agent framework + +An open-source AI agent framework provides inspectable source code for building and running agents. This list covers code-first frameworks, visual builders, and workspaces that can generate workflows from natural-language instructions. The options differ in how much control they give you over execution, state, retrieval, and deployment. For a broader view of these architectural differences, see [AI agent orchestration frameworks explained](https://www.sim.ai/library/ai-agent-orchestration-frameworks-explained). + +License terms determine whether you can modify the software and use it commercially without added restrictions. Self-hosting determines who manages infrastructure, data, and updates. Deployment surfaces show whether one workflow can run through an API, chat interface, or MCP server. + +Flowise receives legacy treatment because its maintainers ended development and [archived the Flowise repository in 2026](https://github.com/FlowiseAI/Flowise). OpenHands receives separate treatment because it automates software development tasks rather than serving as a general-purpose agent builder. + +## 1. Sim: best for a full agent workspace + +[Sim](https://sim.ai) gives you workflow building, persistent resources, and multiple deployment options in one workspace. Its [Apache 2.0 license](https://github.com/simstudioai/sim/blob/main/LICENSE) permits commercial use, modification, and self-hosting without the multi-tenant restrictions found in some modified open-source licenses. + +You can [build workflows](https://docs.sim.ai/introduction) in the visual editor, through APIs and code, or with natural-language instructions in Mothership. Mothership gives you a practical starting point without requiring code, while the visual editor and APIs let you inspect and refine the workflow. + +Native Tables, Files, and Knowledge Bases give agents reusable context inside the same workspace. For example, a support agent can reference uploaded documentation, store structured records in a table, and use those resources across later runs. Keeping these resources in Sim can reduce the number of separate storage services you need to connect and maintain. The [workspace documentation](https://docs.sim.ai/platform/workspaces) describes how these resources fit together. + +A single Sim workflow can serve several interfaces. You can [publish it as a REST API, a hosted chat experience, or a set of MCP tools](https://docs.sim.ai/workflows/deployment). Reusing the same workflow logic across those surfaces reduces the need to maintain separate implementations. See [how to turn a workflow into a reusable MCP tool](https://www.sim.ai/library/how-to-turn-a-workflow-into-a-reusable-mcp-tool) for a closer look at the MCP path. + +Sim does not offer the deepest control for every agent project. [LangGraph gives Python developers finer control over state transitions and execution graphs](https://docs.langchain.com/oss/python/langgraph/overview), while [CrewAI offers a more explicit programming model for role-based agent collaboration](https://docs.crewai.com/v1.13.0/en/concepts/agents). [Dify provides more specialized tooling for retrieval-heavy applications](https://docs.dify.ai/en/self-host/use-dify/knowledge/readme). Sim makes more sense when you value flexible building methods, native workspace resources, and multiple deployment surfaces over maximum code-level control or specialized RAG features. + +## 2. LangGraph — best for developers who want low-level control over agent state + +[LangGraph suits developers who need direct control over stateful agent behavior](https://docs.langchain.com/oss/python/langgraph/overview). Its code-first, Python-oriented model lets you define custom execution logic instead of arranging prebuilt steps on a visual canvas. + +A [`StateGraph` organizes an agent as nodes connected by edges](https://docs.langchain.com/oss/python/langgraph/use-graph-api). Nodes run Python functions or model calls and update shared state, while edges route execution according to the current output. Conditional and loop edges let an agent retry work, revisit an earlier step, or pause for human input. + +Linear chains typically execute once in a fixed direction, so they do not express cycles as naturally. A [persistent checkpointer can preserve graph state](https://docs.langchain.com/oss/python/langgraph/persistence) across failures and restarts. Explicit graph definitions support retries, loops, checkpoints, and human review within the execution path. + +LangGraph and LangChain serve complementary roles. LangChain provides model integrations and higher-level agent components, while LangGraph supplies the underlying state and execution engine. In LangChain and LangGraph 1.0, [LangChain's `create_agent` runs on LangGraph](https://docs.langchain.com/oss/python/releases/langgraph-v1). + +LangGraph requires more engineering work than a visual agent builder, but it gives you direct control over branching, recovery, and long-running execution. [LangSmith adds tracing, debugging, and evaluation](https://docs.smith.langchain.com/old/cookbook) for deployed graphs, but you do not need it to build or run LangGraph workflows. + +## 3. CrewAI — best for role-based multi-agent teams in Python + +[CrewAI suits Python developers who want multiple agents to collaborate through defined roles and delegated tasks](https://docs.crewai.com/v1.13.0/en/concepts/agents). You give each agent a role, goal, and optional backstory that guides its behavior. Crews group agents around shared work, while [Flows add state, branching, loops, and event-driven execution](https://docs.crewai.com/edge/en/concepts/production-architecture). + +For example, a researcher can hand evidence to a writer or reviewer. Our guide to the [best multi-agent frameworks](https://www.sim.ai/library/best-multi-agent-frameworks-2026) explains when this role-based pattern is useful. + +The [MIT-licensed core](https://github.com/crewAIInc/crewAI) remains free and supports local or cloud deployment. Self-hosting gives you control over models and data, but you must operate the runtime and manage scaling, secrets, and monitoring. [Agents can use hosted APIs or open-weight models, and you can assign different models to separate tasks](https://github.com/crewAIInc/crewAI). + +CrewAI separates its open-source framework from its commercial Agent Management Platform. According to [CrewAI's current pricing page](https://crewai.com/pricing), CrewAI AMP adds a visual editor, managed deployment, and enterprise governance features. A limited platform tier remains free, while enterprise capabilities require custom pricing. + +Smaller open-weight models may require extra testing because tool-use reliability varies by model. CrewAI therefore fits best when you can use capable models and want role-based coordination more than low-level graph control. + +## 4. Dify — best for RAG-first LLM applications + +Dify fits applications that retrieve information from documents before generating an answer. Its [visual workflow builder includes knowledge retrieval nodes](https://docs.dify.ai/en/cloud/use-dify/nodes/knowledge-retrieval) that connect retrieval, model calls, conditional logic, and external tools without requiring you to implement each step in code. + +Dify provides dedicated controls for [knowledge bases and document chunking](https://docs.dify.ai/en/cloud/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text), retrieval, source management, and [retrieval testing](https://docs.dify.ai/en/cloud/use-dify/knowledge/test-retrieval). These controls let you test how retrieved passages affect generated responses. Those features suit support assistants, internal search tools, and document-based chat applications. + +You can use Dify through its hosted cloud service or [run it on your own infrastructure](https://docs.dify.ai/en/self-host/deploy/overview). Dify also offers a [self-hosted enterprise edition](https://dify.ai/pricing/dify-enterprise) with additional administration and support features. The platform [supports multiple model providers](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers), which reduces dependence on a single API. + +Dify uses an [Apache 2.0-based license with added conditions](https://github.com/langgenius/dify/blob/main/LICENSE), including restrictions on operating a commercial multi-tenant service without separate permission. Review the license before offering Dify as a hosted product. Dify is oriented toward retrieval-focused applications, while [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview) and [CrewAI](https://docs.crewai.com/edge/en/concepts/production-architecture) provide more direct control over custom agent orchestration. + +## 5. Flowise — a formerly popular visual builder, now archived + +Flowise is no longer a recommendation for new projects. The [Flowise GitHub repository](https://github.com/FlowiseAI/Flowise) is archived and read-only. No Flowise Cloud shutdown date should be stated without a primary announcement from Flowise. + +Existing self-hosted installations can continue running, but the repository is read-only and does not receive upstream fixes while it remains archived. You must maintain a private fork or replace Flowise as model APIs, dependencies, and security requirements change. Flowise Cloud users should check the service's official notices for any migration deadline. + +Flowise previously offered a practical visual builder for LLM applications; [most of its source was available under Apache 2.0, with specified enterprise exceptions](https://github.com/FlowiseAI/Flowise/blob/32d80d352022480f894a534afa87458f00d434e6/LICENSE.md). [Its drag-and-drop canvas let you connect models, tools, retrieval components, and agent steps without writing the entire application in code, with self-hosting options including npm and Docker](https://github.com/FlowiseAI/Flowise). + +For a new deployment, choose an actively maintained option. Sim covers visual and natural-language workflow building, while Dify provides deeper tooling for retrieval-focused applications. The [best no-code AI agent builders](https://www.sim.ai/library/best-no-code-ai-agent-builders-2026) comparison covers more actively maintained visual options. + +## 6. OpenHands: best for autonomous software development + +OpenHands is an autonomous software engineering platform rather than a general-purpose agent builder. It appears separately because buyers use it to complete coding and software delivery tasks, not to build broad business workflows. + +[OpenHands agents can inspect repositories, plan code changes, and apply them in a working environment](https://github.com/All-Hands-AI/OpenHands/). They can [review pull requests, triage issues, and react to CI or other GitHub events](https://docs.openhands.dev/openhands/usage/automations/event-automations). OpenHands also supports software development lifecycle tasks through [GitHub, GitLab, Bitbucket, and Slack integrations](https://docs.openhands.dev/openhands/usage/settings/integrations-settings), while availability varies by deployment. + +The [open-source core uses the MIT license](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE) and can run locally, while [OpenHands also offers cloud and self-hosted enterprise deployments](https://www.openhands.dev/pricing). Its current repository describes support for OpenHands and other compatible coding agents across local, remote, and cloud backends. + +Choose OpenHands when you want an agent to perform engineering work with repository and command-line access. Evaluate OpenHands on its repository access, coding environment, and software delivery capabilities rather than on general-purpose workspace features. + +## Comparison table + +| Framework | Build model | License | Self-hosting | Deployment surfaces | Model flexibility | +| --- | --- | --- | --- | --- | --- | +| Sim | [Visual, API/code, and natural language](https://docs.sim.ai/introduction) | [Apache 2.0](https://github.com/simstudioai/sim/blob/main/LICENSE) | Yes | [API, chat, and MCP](https://docs.sim.ai/workflows/deployment) | [Multiple hosted and bring-your-own-key models](https://docs.sim.ai/introduction); local-model availability varies by deployment | +| LangGraph | [Code-first graphs](https://docs.langchain.com/oss/python/langgraph/use-graph-api) | [MIT](https://github.com/langchain-ai/langgraph/blob/main/LICENSE) | Yes | [Applications, APIs, and deployments](https://docs.langchain.com/oss/python/langgraph/overview) | Broad LangChain model ecosystem | +| CrewAI | [Python crews and flows](https://docs.crewai.com/edge/en/concepts/flows) | [MIT](https://github.com/crewAIInc/crewAI) | Yes | Python applications and [managed AMP deployments](https://crewai.com/pricing) | [API and open-weight models](https://github.com/crewAIInc/crewAI) | +| Dify | [Visual workflows](https://docs.dify.ai/en/cloud/use-dify/nodes/knowledge-retrieval) | [Modified Apache 2.0 with commercial restrictions](https://github.com/langgenius/dify/blob/main/LICENSE) | [Community and enterprise options](https://dify.ai/pricing/dify-enterprise) | [Applications and APIs](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval) | [Multiple model providers](https://docs.dify.ai/en/self-host/use-dify/workspace/model-providers) | +| Flowise | [Visual canvas](https://github.com/FlowiseAI/Flowise) | [Apache 2.0 for most source, with enterprise exceptions](https://github.com/FlowiseAI/Flowise/blob/32d80d352022480f894a534afa87458f00d434e6/LICENSE.md) | Existing installations require user-managed maintenance | [Web app and API](https://github.com/FlowiseAI/Flowise) | [Multiple model providers, but no upstream updates while archived](https://github.com/FlowiseAI/Flowise) | +| OpenHands¹ | [Coding-agent platform](https://github.com/All-Hands-AI/OpenHands/) | [MIT for the open-source core](https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE) | [Local and enterprise options](https://www.openhands.dev/pricing) | Local, cloud, or enterprise deployments | Multiple compatible coding agents and models | + +¹ OpenHands serves software engineering workflows rather than general-purpose agent building. + +## How to choose + +- Choose [LangGraph](https://docs.langchain.com/oss/python/langgraph/use-graph-api) when you need precise control over state, branching, and loops in code. Choose [CrewAI](https://docs.crewai.com/v1.13.0/en/concepts/agents) when Python agents need distinct roles and collaborative tasks. +- Choose [Dify](https://docs.dify.ai/en/self-host/use-dify/knowledge/readme) when retrieval quality and knowledge-base management drive the application. Its RAG tooling goes deeper than the general-purpose builders covered here. +- Choose [Sim](https://sim.ai) when you need a shared workspace with natural-language, visual, and code-based building. Native Tables, Files, and Knowledge Bases supply workflow context, while one workflow can deploy through API, chat, or MCP. +- Choose [OpenHands](https://docs.openhands.dev/openhands/usage/automations/event-automations) when agents need to review pull requests, fix CI failures, or automate other software development tasks. It serves coding workflows rather than general agent building. +- For a new project, choose an actively maintained alternative to [Flowise](https://github.com/FlowiseAI/Flowise). Existing Flowise users should assess migration options and decide whether they can maintain a private fork. + +If you need one workspace for natural-language, visual, and code-based building with multiple deployment options, consider [Sim](https://sim.ai). diff --git a/apps/sim/public/library/best-open-source-ai-agent-frameworks/cover.jpg b/apps/sim/public/library/best-open-source-ai-agent-frameworks/cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6651b5037b7a930cb6f4fe33e608c99be240bec0 GIT binary patch literal 24146 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 Date: Wed, 9 Sep 2026 17:42:25 -0700 Subject: [PATCH 09/30] fix(knowledge): resolve Drive shortcuts during connector sync (#7701) --- .../google-drive/google-drive-errors.ts | 43 +- .../google-drive/google-drive.test.ts | 8 +- .../connectors/google-drive/google-drive.ts | 446 +++++++++++++++--- .../connectors/google-drive/shortcuts.test.ts | 352 ++++++++++++++ apps/sim/connectors/source-error.ts | 19 + .../google-drive-shortcuts.integration.ts | 359 ++++++++++++++ .../connectors/connector-error.test.ts | 17 + .../knowledge/connectors/connector-error.ts | 25 +- 8 files changed, 1168 insertions(+), 101 deletions(-) create mode 100644 apps/sim/connectors/google-drive/shortcuts.test.ts create mode 100644 apps/sim/connectors/source-error.ts create mode 100644 apps/sim/lib/knowledge/__integration__/google-drive-shortcuts.integration.ts diff --git a/apps/sim/connectors/google-drive/google-drive-errors.ts b/apps/sim/connectors/google-drive/google-drive-errors.ts index 28c2de91548..b57cd059b27 100644 --- a/apps/sim/connectors/google-drive/google-drive-errors.ts +++ b/apps/sim/connectors/google-drive/google-drive-errors.ts @@ -5,6 +5,10 @@ import { resolveRetryDelayMs, retryWithExponentialBackoff, } from '@/lib/knowledge/documents/utils' +import { + ConnectorSourceError, + type ConnectorSourceFailureCategory, +} from '@/connectors/source-error' import { readBodyWithLimit } from '@/connectors/utils' const GOOGLE_ERROR_BODY_MAX_BYTES = 64 * 1024 @@ -103,22 +107,47 @@ function classifyGoogleDriveError( return 'unknown' } -export class GoogleDriveApiError extends Error { +function diagnosticCategory( + kind: GoogleDriveErrorKind, + status: number +): ConnectorSourceFailureCategory | undefined { + switch (kind) { + case 'authorization': + case 'permission': + return 'authorization' + case 'not_found': + return 'source_unavailable' + case 'export_too_large': + case 'unsupported_export': + case 'policy': + return 'request_rejected' + case 'quota': + return 'rate_limit' + case 'transient': + return status === 429 || status === 403 ? 'rate_limit' : 'provider_unavailable' + default: + return undefined + } +} + +export class GoogleDriveApiError extends ConnectorSourceError { retryAfterMs?: number readonly reasons: readonly string[] readonly kind: GoogleDriveErrorKind readonly rateLimited: boolean - constructor( - readonly status: number, - normalizedReasons: readonly string[] - ) { + constructor(status: number, normalizedReasons: readonly string[]) { const diagnosticReasons = normalizedReasons.slice(0, GOOGLE_ERROR_REASON_MAX_COUNT) const reasonSuffix = diagnosticReasons.length > 0 ? ` (${diagnosticReasons.join(', ')})` : '' - super(`Google Drive API request failed with HTTP ${status}${reasonSuffix}`) + const kind = classifyGoogleDriveError(status, normalizedReasons) + super( + `Google Drive API request failed with HTTP ${status}${reasonSuffix}`, + status, + diagnosticCategory(kind, status) + ) this.name = 'GoogleDriveApiError' this.reasons = diagnosticReasons - this.kind = classifyGoogleDriveError(status, normalizedReasons) + this.kind = kind this.rateLimited = status === 429 || normalizedReasons.some((reason) => RATE_LIMIT_REASONS.has(reason)) } diff --git a/apps/sim/connectors/google-drive/google-drive.test.ts b/apps/sim/connectors/google-drive/google-drive.test.ts index b9f3e7fb71c..c093c2504b7 100644 --- a/apps/sim/connectors/google-drive/google-drive.test.ts +++ b/apps/sim/connectors/google-drive/google-drive.test.ts @@ -928,8 +928,12 @@ describe('Google Drive change feed', () => { { kind: 'removed', externalId: 'moved-out' }, { kind: 'removed', externalId: 'video' }, ]) - expect(result.nextCursor).toBe('5000') - expect(result.hasMore).toBe(false) + expect(result.nextCursor).toMatch(/^gdrive-shortcuts:v1:/) + expect(result.hasMore).toBe(true) + mockFetch.mockResolvedValueOnce(jsonResponse({ files: [] })) + await expect( + googleDriveConnector.listChanges!('token', {}, result.nextCursor!) + ).resolves.toEqual({ changes: [], nextCursor: '5000', hasMore: false }) const url = new URL(String(mockFetch.mock.calls[0][0])) expect(url.searchParams.get('pageToken')).toBe('4821') expect(url.searchParams.get('includeRemoved')).toBe('true') diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index 0fa102e9c21..cd551cd9066 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -7,6 +7,7 @@ import { driveFileAcl, type OpenSharingPolicy, } from '@/lib/knowledge/access/drive-permissions' +import type { MirroredDocumentAcl } from '@/lib/knowledge/access/types' import { OCR_IMAGE_MIME_TYPES } from '@/lib/knowledge/documents/ocr-request-policy' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { drainGooglePagedList } from '@/lib/oauth/google-pagination' @@ -60,6 +61,13 @@ const GOOGLE_WORKSPACE_EXPORTS: Record = { 'application/vnd.google-apps.spreadsheet': XLSX_MIME_TYPE, 'application/vnd.google-apps.presentation': 'text/plain', } +const SHORTCUT_MIME_TYPE = 'application/vnd.google-apps.shortcut' +const SHORTCUT_FETCH_CONCURRENCY = 8 +const DRIVE_METADATA_MAX_BYTES = 1024 * 1024 +const DRIVE_PAGE_MAX_BYTES = 16 * 1024 * 1024 +const DRIVE_FILE_FIELDS = + 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed,parents,shortcutDetails(targetId,targetMimeType,targetResourceKey)' + const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' const SUPPORTED_TEXT_MIME_TYPES = [ @@ -100,6 +108,7 @@ function isSupportedTextFile(mimeType: string): boolean { } function rawFileType(file: DriveFile): { mimeType: string; fileName: string } | undefined { + if (file.mimeType.startsWith('application/vnd.google-apps.')) return undefined const byName = pipelineParsedMimeType(file.name) if (byName) return { mimeType: byName, fileName: file.name } if (OCR_IMAGE_MIME_TYPES.has(file.mimeType)) { @@ -124,7 +133,8 @@ function isSupportedFile(file: DriveFile): boolean { async function exportGoogleWorkspaceFile( accessToken: string, fileId: string, - sourceMimeType: string + sourceMimeType: string, + resourceKey?: string ): Promise { const exportMimeType = GOOGLE_WORKSPACE_EXPORTS[sourceMimeType] if (!exportMimeType) { @@ -137,7 +147,7 @@ async function exportGoogleWorkspaceFile( try { response = await fetchGoogleDriveWithRetry(url, { method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, + headers: driveRequestHeaders(accessToken, fileId, resourceKey), }) } catch (error) { if (error instanceof GoogleDriveApiError && error.kind === 'export_too_large') { @@ -153,7 +163,11 @@ async function exportGoogleWorkspaceFile( return buffer } -async function downloadFile(accessToken: string, fileId: string): Promise { +async function downloadFile( + accessToken: string, + fileId: string, + resourceKey?: string +): Promise { // Listing runs with `includeItemsFromAllDrives`, so ids here can belong to a shared // drive; `supportsAllDrives` declares that support to `files.get` the same way the // metadata fetch in getDocument already does. (`files.export` takes no such param.) @@ -161,7 +175,7 @@ async function downloadFile(accessToken: string, fileId: string): Promise { +async function fetchFilePayload( + accessToken: string, + file: DriveFile, + resourceKey?: string +): Promise { if (GOOGLE_WORKSPACE_EXPORTS[file.mimeType]) { - const bytes = await exportGoogleWorkspaceFile(accessToken, file.id, file.mimeType) + const bytes = await exportGoogleWorkspaceFile(accessToken, file.id, file.mimeType, resourceKey) if (file.mimeType === 'application/vnd.google-apps.spreadsheet') { return { content: '', @@ -197,7 +215,7 @@ async function fetchFilePayload(accessToken: string, file: DriveFile): Promise '${lastSyncAt.toISOString()}'`) + if (lastSyncAt) { + /** Target edits do not modify the shortcut itself. */ + parts.push( + `(modifiedTime > '${lastSyncAt.toISOString()}' or mimeType = '${SHORTCUT_MIME_TYPE}')` + ) + } const fileType = (sourceConfig.fileType as string) || 'all' const mimeParts: string[] = [] @@ -460,6 +484,8 @@ function buildQuery( } } if (mimeParts.length > 0) { + /** Resolve the current target type: shortcutDetails.targetMimeType can be stale. */ + mimeParts.push(`mimeType = '${SHORTCUT_MIME_TYPE}'`) if (includeFolders) mimeParts.push(`mimeType = '${FOLDER_MIME_TYPE}'`) parts.push(`(${mimeParts.join(' or ')})`) } @@ -540,7 +566,8 @@ const DRIVE_PERMISSION_FIELDS = 'id,type,emailAddress,domain,role,allowFileDisco */ async function listFilePermissions( accessToken: string, - fileId: string + fileId: string, + resourceKey?: string ): Promise { const { items, truncated } = await drainGooglePagedList< DrivePermission, @@ -558,7 +585,7 @@ async function listFilePermissions( fetch: (url) => fetchGoogleDriveWithRetry(url, { method: 'GET', - headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + headers: driveRequestHeaders(accessToken, fileId, resourceKey), }), parseError: (response) => response.json().catch(() => null), getItems: (body) => body.permissions, @@ -583,15 +610,36 @@ async function listFilePermissions( async function resolveDriveAcls( accessToken: string, sourceConfig: Record, - externalIds: string[], + documents: readonly ExternalDocument[], syncContext?: Record -): Promise> { +): Promise> { const context = driveAclContext(sourceConfig, syncContext) if (!context) return {} - const acls: Record = {} - await mapWithConcurrency(externalIds, PERMISSION_FETCH_CONCURRENCY, async (fileId) => { + const acls: Record = {} + await mapWithConcurrency(documents, PERMISSION_FETCH_CONCURRENCY, async (document) => { + const fileId = document.externalId try { + if (document.metadata?.shortcutTargetId) { + const file = await readDriveFile(accessToken, fileId, undefined, true) + if (file.trashed) { + acls[fileId] = [] + return + } + if (file.mimeType === SHORTCUT_MIME_TYPE) { + const target = await readShortcutTarget(accessToken, file, true) + acls[fileId] = + target && isSupportedFile(target.file) + ? await shortcutAcl(accessToken, file, target.file, context, target.resourceKey) + : [] + return + } + const listedAcl = fileAcl(file, context) + if (listedAcl) { + acls[fileId] = listedAcl + return + } + } const permissions = await listFilePermissions(accessToken, fileId) acls[fileId] = driveFileAcl({ ...context, permissions }) } catch (error) { @@ -604,7 +652,11 @@ async function resolveDriveAcls( return acls } -function fileToStub(file: DriveFile, acl?: string[]): ExternalDocument { +function fileToStub( + file: DriveFile, + acl?: MirroredDocumentAcl, + target?: DriveFile +): ExternalDocument { /** * Sheets moved from a first-sheet-only CSV export to the complete XLSX source. * The namespace forces one rehydration for existing rows whose old hash would @@ -621,18 +673,260 @@ function fileToStub(file: DriveFile, acl?: string[]): ExternalDocument { ...(acl ? { acl } : {}), mimeType: 'text/plain', sourceUrl: file.webViewLink || `https://drive.google.com/file/d/${file.id}/view`, - contentHash: `${hashNamespace}:${file.id}:${file.modifiedTime ?? ''}`, + contentHash: target + ? `gdrive:shortcut:v1:${file.id}:${file.modifiedTime ?? ''}:${target.id}:${target.modifiedTime ?? ''}:${target.mimeType}` + : `${hashNamespace}:${file.id}:${file.modifiedTime ?? ''}`, metadata: { - originalMimeType: file.mimeType, - modifiedTime: file.modifiedTime, + originalMimeType: target?.mimeType ?? file.mimeType, + ...(target ? { shortcutTargetId: target.id } : {}), + modifiedTime: target?.modifiedTime ?? file.modifiedTime, createdTime: file.createdTime, owners: file.owners?.map((o) => o.displayName || o.emailAddress).filter(Boolean), starred: file.starred, - fileSize: file.size ? Number(file.size) : undefined, + fileSize: (target ?? file).size ? Number((target ?? file).size) : undefined, }, } } +/** Resource keys are capabilities: send only to Drive, never persist them in document metadata. */ +function driveRequestHeaders( + accessToken: string, + fileId: string, + resourceKey?: string +): Record { + if ( + resourceKey !== undefined && + (!/^[A-Za-z0-9_-]{1,1024}$/.test(resourceKey) || !/^[A-Za-z0-9_-]{1,1024}$/.test(fileId)) + ) { + throw new Error('Google Drive returned malformed resource-key metadata') + } + return { + Authorization: `Bearer ${accessToken}`, + ...(resourceKey ? { 'X-Goog-Drive-Resource-Keys': `${fileId}/${resourceKey}` } : {}), + } +} + +async function readDriveJson(response: Response, limitBytes: number): Promise { + const body = await readBodyWithLimit(response, limitBytes) + if (!body) throw new Error('Google Drive metadata exceeded its size limit') + try { + return JSON.parse(body.toString('utf8')) + } catch { + throw new Error('Google Drive returned malformed metadata') + } +} + +async function readDriveFile( + accessToken: string, + fileId: string, + resourceKey?: string, + permissions = false +): Promise { + const fields = `${DRIVE_FILE_FIELDS}${permissions ? `,permissions(${DRIVE_PERMISSION_FIELDS})` : ''}` + const response = await fetchGoogleDriveWithRetry( + `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true`, + { method: 'GET', headers: driveRequestHeaders(accessToken, fileId, resourceKey) } + ) + return parseDriveFileMetadata(await readDriveJson(response, DRIVE_METADATA_MAX_BYTES), fileId) +} + +/** Resolve one current file target; folder traversal and shortcut chains are not expanded. */ +async function readShortcutTarget( + accessToken: string, + shortcut: DriveFile, + permissions = false +): Promise<{ file: DriveFile; resourceKey?: string } | null> { + const details = shortcut.shortcutDetails + if ( + !details || + typeof details.targetId !== 'string' || + !/^[A-Za-z0-9_-]{1,1024}$/.test(details.targetId) || + (details.targetResourceKey !== undefined && typeof details.targetResourceKey !== 'string') + ) { + throw new Error('Google Drive returned malformed shortcut metadata') + } + if (details.targetId === shortcut.id) throw new Error('Google Drive returned a cyclic shortcut') + try { + const file = await readDriveFile( + accessToken, + details.targetId, + details.targetResourceKey, + permissions + ) + if (!file.trashed && !isDriveFileListItem(file)) { + throw new Error('Google Drive returned malformed shortcut target metadata') + } + return file.trashed ? null : { file, resourceKey: details.targetResourceKey } + } catch (error) { + if ( + error instanceof GoogleDriveApiError && + (error.kind === 'not_found' || error.kind === 'permission') + ) + return null + throw error + } +} + +function unavailableShortcut(file: DriveFile): ExternalDocument { + return { + ...markSkipped( + fileToStub(file, []), + 'Shortcut target is unavailable or the connector account cannot access it' + ), + skippedExistingDisposition: 'replace', + } +} + +/** A shortcut's own grants never grant access to its target's content. */ +async function shortcutAcl( + accessToken: string, + shortcut: DriveFile, + target: DriveFile, + context: DriveAclContext, + resourceKey?: string +): Promise { + return { + acl: + fileAcl(shortcut, context) ?? + driveFileAcl({ + ...context, + permissions: await listFilePermissions(accessToken, shortcut.id), + }), + requirements: [ + fileAcl(target, context) ?? + driveFileAcl({ + ...context, + permissions: await listFilePermissions(accessToken, target.id, resourceKey), + }), + ], + } +} + +async function listedFileToDocument( + accessToken: string, + sourceConfig: Record, + file: DriveFile, + syncContext?: Record +): Promise { + if (file.trashed || file.mimeType === FOLDER_MIME_TYPE) return null + const context = driveAclContext(sourceConfig, syncContext) + let target: DriveFile | undefined + let acl: MirroredDocumentAcl | undefined = fileAcl(file, context) + if (file.mimeType === SHORTCUT_MIME_TYPE) { + let resolved: Awaited> + try { + resolved = await readShortcutTarget(accessToken, file, Boolean(context)) + } catch (error) { + /** Member visibility requires verified target access; a failed page never grants it. */ + if (isPerMemberListing(syncContext)) throw error + logger.warn( + 'Could not resolve shortcut target; deferring to content hydration', + googleDriveErrorLogFields(error) + ) + return fileToStub(file, []) + } + if (!resolved) return isPerMemberListing(syncContext) ? null : unavailableShortcut(file) + target = resolved.file + if (context) { + try { + acl = await shortcutAcl(accessToken, file, target, context, resolved.resourceKey) + } catch (error) { + logger.warn( + 'Could not verify shortcut and target permissions', + googleDriveErrorLogFields(error) + ) + acl = [] + } + } + } + const contentFile = target ?? file + if (!matchesFileType((sourceConfig.fileType as string) || 'all', contentFile)) return null + return stubOrSkipBySize( + fileToStub(file, acl, target), + Number(contentFile.size) || undefined, + CONNECTOR_MAX_FILE_BYTES + ) +} + +const SHORTCUT_CHANGE_CURSOR_PREFIX = 'gdrive-shortcuts:v1:' +interface ShortcutChangeCursor { + resume: string + pageToken?: string +} + +function writeShortcutChangeCursor(cursor: ShortcutChangeCursor): string { + return `${SHORTCUT_CHANGE_CURSOR_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString('base64url')}` +} + +function readShortcutChangeCursor(cursor: string): ShortcutChangeCursor | undefined { + if (!cursor.startsWith(SHORTCUT_CHANGE_CURSOR_PREFIX)) return undefined + try { + if (cursor.length > 65536) throw new Error() + const value: unknown = JSON.parse( + Buffer.from(cursor.slice(SHORTCUT_CHANGE_CURSOR_PREFIX.length), 'base64url').toString('utf8') + ) + if ( + !isPlainRecord(value) || + typeof value.resume !== 'string' || + !value.resume || + value.resume.length > 16384 || + (value.pageToken !== undefined && + (typeof value.pageToken !== 'string' || !value.pageToken || value.pageToken.length > 16384)) + ) + throw new Error() + return { resume: value.resume, pageToken: value.pageToken } + } catch { + throw new InvalidDriveListingCursor('Google Drive shortcut listing must restart') + } +} + +/** Target edits and revocations need not emit a change for their shortcuts. */ +async function listShortcutChanges( + accessToken: string, + sourceConfig: Record, + cursor: ShortcutChangeCursor +): Promise { + const params = new URLSearchParams({ + q: `trashed = false and mimeType = '${SHORTCUT_MIME_TYPE}'`, + orderBy: 'createdTime', + fields: `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`, + pageSize: '100', + supportsAllDrives: 'true', + includeItemsFromAllDrives: 'true', + }) + if (cursor.pageToken) params.set('pageToken', cursor.pageToken) + const response = await fetchGoogleDriveWithRetry( + `https://www.googleapis.com/drive/v3/files?${params}`, + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + } + ) + const page = parseDriveFileListResponse(await readDriveJson(response, DRIVE_PAGE_MAX_BYTES)) + if (page.incompleteSearch) throw new Error('Google Drive shortcut search was incomplete') + if (page.nextPageToken && page.nextPageToken === cursor.pageToken) + throw new Error('Google Drive repeated a shortcut continuation token') + const changes = await mapWithConcurrency( + page.files, + SHORTCUT_FETCH_CONCURRENCY, + async (file): Promise => { + const document = await listedFileToDocument(accessToken, sourceConfig, file, { + perMemberListing: true, + }) + return document + ? { kind: 'upsert', externalId: file.id, document } + : { kind: 'removed', externalId: file.id } + } + ) + return { + changes, + nextCursor: page.nextPageToken + ? writeShortcutChangeCursor({ ...cursor, pageToken: page.nextPageToken }) + : cursor.resume, + hasMore: Boolean(page.nextPageToken), + } +} + const TREE_CURSOR_PREFIX = 'gdrive-tree:1:' const MAX_TREE_CURSOR_BYTES = 512 * 1024 const MAX_PENDING_FOLDERS = 10_000 @@ -754,7 +1048,7 @@ export const googleDriveConnector: ConnectorConfig = { * Permissions ride along only where the run mirrors them. Every other * crawl would pull a permission array per file and discard it. */ - fields: `kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,parents${ + fields: `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS}${ aclContext ? `,permissions(${DRIVE_PERMISSION_FIELDS})` : '' })`, supportsAllDrives: 'true', @@ -796,7 +1090,7 @@ export const googleDriveConnector: ConnectorConfig = { throw error } - const data = parseDriveFileListResponse(await response.json()) + const data = parseDriveFileListResponse(await readDriveJson(response, DRIVE_PAGE_MAX_BYTES)) const files = data.files /** @@ -829,19 +1123,10 @@ export const googleDriveConnector: ConnectorConfig = { } } - const pageDocuments = files - .filter( - (f) => - f.mimeType !== FOLDER_MIME_TYPE && - matchesFileType((sourceConfig.fileType as string) || 'all', f) - ) - .map((f) => - stubOrSkipBySize( - fileToStub(f, fileAcl(f, aclContext)), - Number(f.size) || undefined, - CONNECTOR_MAX_FILE_BYTES - ) - ) + const resolved = await mapWithConcurrency(files, SHORTCUT_FETCH_CONCURRENCY, (file) => + listedFileToDocument(accessToken, sourceConfig, file, syncContext) + ) + const pageDocuments = resolved.filter((doc): doc is ExternalDocument => doc !== null) const page = takeIndexableWithinCap( pageDocuments, @@ -891,74 +1176,60 @@ export const googleDriveConnector: ConnectorConfig = { ), getDocumentAcls: (accessToken, sourceConfig, documents, syncContext) => - resolveDriveAcls( - accessToken, - sourceConfig, - documents.map((doc) => doc.externalId), - syncContext - ), + resolveDriveAcls(accessToken, sourceConfig, documents, syncContext), getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string ): Promise => { - const fields = - 'id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed' - const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(externalId)}?fields=${encodeURIComponent(fields)}&supportsAllDrives=true` - - let response: Response + let file: DriveFile try { - response = await fetchGoogleDriveWithRetry(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) + file = await readDriveFile(accessToken, externalId) } catch (error) { - if (!(error instanceof GoogleDriveApiError)) throw error - if (error.kind === 'not_found') return null + if (error instanceof GoogleDriveApiError && error.kind === 'not_found') return null throw error } - - const file = parseDriveFileMetadata(await response.json(), externalId) - if (file.trashed) return null + let contentFile = file + let resourceKey: string | undefined + if (file.mimeType === SHORTCUT_MIME_TYPE) { + const resolved = await readShortcutTarget(accessToken, file) + if (!resolved) return unavailableShortcut(file) + contentFile = resolved.file + resourceKey = resolved.resourceKey + } + const stub = fileToStub(file, undefined, contentFile === file ? undefined : contentFile) /** * Mirrors the listing filter. The marker distinguishes a successfully * verified unindexable file from an ambiguous null hydration. */ - if (!isSupportedFile(file)) { + if (!matchesFileType((sourceConfig.fileType as string) || 'all', contentFile)) { logger.info('Google Drive file has no extractable text type', { fileId: file.id, mimeType: file.mimeType, }) return { - ...markSkipped(fileToStub(file), 'File is no longer an indexable document'), + ...markSkipped(stub, 'File is no longer an indexable document'), skippedExistingDisposition: 'replace', } } try { - const payload = await fetchFilePayload(accessToken, file) + const payload = await fetchFilePayload(accessToken, contentFile, resourceKey) if (!payload.content.trim() && !payload.sourceFile?.bytes.length) { return { - ...markSkipped( - { ...fileToStub(file), ...payload }, - 'Document contains no extractable text' - ), + ...markSkipped({ ...stub, ...payload }, 'Document contains no extractable text'), skippedExistingDisposition: 'replace', } } - const stub = fileToStub(file) return { ...stub, ...payload, contentDeferred: false } } catch (error) { if (error instanceof ConnectorFileTooLargeError) { logger.info('Skipping oversized Google Drive file', { fileId: file.id, name: file.name }) - return markSkipped(fileToStub(file), sizeLimitSkipReason(error.limitBytes)) + return markSkipped(stub, sizeLimitSkipReason(error.limitBytes)) } /** * The file exists but its content could not be read. Propagate so the engine @@ -1119,6 +1390,10 @@ export const googleDriveConnector: ConnectorConfig = { sourceConfig: Record, cursor: string ): Promise => { + const shortcutCursor = readShortcutChangeCursor(cursor) + if (shortcutCursor) { + return listShortcutChanges(accessToken, sourceConfig, shortcutCursor) + } const queryParams = new URLSearchParams({ pageToken: cursor, pageSize: '100', @@ -1127,8 +1402,7 @@ export const googleDriveConnector: ConnectorConfig = { includeItemsFromAllDrives: 'true', restrictToMyDrive: 'false', spaces: 'drive', - fields: - 'nextPageToken,newStartPageToken,changes(changeType,removed,fileId,file(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,trashed,parents))', + fields: `nextPageToken,newStartPageToken,changes(changeType,removed,fileId,file(${DRIVE_FILE_FIELDS}))`, }) const url = `https://www.googleapis.com/drive/v3/changes?${queryParams.toString()}` @@ -1143,20 +1417,36 @@ export const googleDriveConnector: ConnectorConfig = { throw error } - const data = parseDriveChangeListResponse(await response.json()) - const changes: ExternalChange[] = [] - for (const change of data.changes) { - const mapped = driveChangeToExternal(change, sourceConfig) - if (mapped) changes.push(mapped) - } + const data = parseDriveChangeListResponse(await readDriveJson(response, DRIVE_PAGE_MAX_BYTES)) + const mappedChanges = await mapWithConcurrency( + data.changes, + SHORTCUT_FETCH_CONCURRENCY, + async (change) => { + if (!change.removed && change.file?.mimeType === SHORTCUT_MIME_TYPE) { + const document = await listedFileToDocument(accessToken, sourceConfig, change.file, { + perMemberListing: true, + }) + return document + ? { kind: 'upsert' as const, externalId: change.fileId!, document } + : { kind: 'removed' as const, externalId: change.fileId! } + } + return driveChangeToExternal(change, sourceConfig) + } + ) + const changes = mappedChanges.filter((change): change is ExternalChange => change !== null) const nextCursor = data.nextPageToken ?? data.newStartPageToken if (!nextCursor) { throw new Error('Google Drive API returned malformed change-list metadata') } - return { changes, nextCursor, hasMore: Boolean(data.nextPageToken) } + return { + changes, + nextCursor: data.nextPageToken ?? writeShortcutChangeCursor({ resume: nextCursor }), + hasMore: true, + } }, - isChangeCursorInvalidError: isDriveChangeCursorInvalidError, + isChangeCursorInvalidError: (error) => + error instanceof InvalidDriveListingCursor || isDriveChangeCursorInvalidError(error), isListingCursorInvalidError: (error) => error instanceof InvalidDriveListingCursor || isDriveChangeCursorInvalidError(error), } diff --git a/apps/sim/connectors/google-drive/shortcuts.test.ts b/apps/sim/connectors/google-drive/shortcuts.test.ts new file mode 100644 index 00000000000..b1c110184b6 --- /dev/null +++ b/apps/sim/connectors/google-drive/shortcuts.test.ts @@ -0,0 +1,352 @@ +/** @vitest-environment node */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })) +vi.mock('@/components/icons', () => ({ GoogleDriveIcon: () => null })) + +import { googleDriveConnector as drive } from '@/connectors/google-drive/google-drive' +import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors' +import { CONNECTOR_MAX_FILE_BYTES } from '@/connectors/utils' + +const shortcutMime = 'application/vnd.google-apps.shortcut' +const docMime = 'application/vnd.google-apps.document' +const json = (body: unknown) => + new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }) +const denied = (reason = 'notFound', status = 404) => + new Response(JSON.stringify({ error: { errors: [{ reason }] } }), { status }) +function file(overrides: Record = {}) { + return { + id: 'target', + name: 'Target.pdf', + mimeType: 'application/pdf', + modifiedTime: '2026-01-01T00:00:00Z', + ...overrides, + } +} +function shortcut(overrides: Record = {}) { + return file({ + id: 'shortcut', + name: 'Alias.pdf', + mimeType: shortcutMime, + shortcutDetails: { targetId: 'target', targetMimeType: 'application/pdf' }, + ...overrides, + }) +} +const urlAt = (index: number) => new URL(String(fetchMock.mock.calls[index][0])) + +describe('Drive file shortcuts', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('downloads the target PDF and keeps the shortcut identity and listing hash', async () => { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(json(file())) + const listing = await drive.listDocuments('token', {}) + expect(listing.documents).toHaveLength(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce(json(file())) + .mockResolvedValueOnce(new Response('%PDF-fixture')) + const hydrated = await drive.getDocument('token', {}, 'shortcut') + expect(hydrated).toMatchObject({ + externalId: 'shortcut', + title: 'Alias.pdf', + contentHash: listing.documents[0].contentHash, + contentDeferred: false, + mimeType: 'application/pdf', + sourceFile: { fileName: 'Target.pdf', bytes: Buffer.from('%PDF-fixture') }, + }) + expect(urlAt(4).pathname).toBe('/drive/v3/files/target') + expect(urlAt(4).searchParams.get('alt')).toBe('media') + }) + + it('uses current target MIME and filename instead of stale shortcut hints', async () => { + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce(json(file({ name: 'Native document', mimeType: docMime }))) + .mockResolvedValueOnce(new Response('Native content')) + expect(await drive.getDocument('token', {}, 'shortcut')).toMatchObject({ + content: 'Native content', + mimeType: 'text/plain', + }) + expect(urlAt(2).pathname).toBe('/drive/v3/files/target/export') + }) + + it('sends resource keys to target metadata and content only, without persisting them', async () => { + const alias = shortcut({ + shortcutDetails: { targetId: 'target', targetResourceKey: 'synthetic-resource-key' }, + }) + fetchMock + .mockResolvedValueOnce(json(alias)) + .mockResolvedValueOnce(json(file())) + .mockResolvedValueOnce(new Response('pdf')) + const hydrated = await drive.getDocument('token', {}, 'shortcut') + expect(new Headers(fetchMock.mock.calls[0][1].headers).has('X-Goog-Drive-Resource-Keys')).toBe( + false + ) + for (const index of [1, 2]) + expect( + new Headers(fetchMock.mock.calls[index][1].headers).get('X-Goog-Drive-Resource-Keys') + ).toBe('target/synthetic-resource-key') + expect(JSON.stringify(hydrated)).not.toContain('synthetic-resource-key') + }) + + it('observes target edits during incremental listings without downloading unchanged content', async () => { + const hashes = [] + for (const modifiedTime of ['2026-01-01', '2026-01-02']) { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(json(file({ modifiedTime }))) + const page = await drive.listDocuments('token', {}, undefined, {}, new Date('2026-01-01')) + hashes.push(page.documents[0].contentHash) + } + expect(hashes[0]).not.toBe(hashes[1]) + expect(urlAt(0).searchParams.get('q')).toContain(`or mimeType = '${shortcutMime}'`) + expect( + fetchMock.mock.calls.every(([url]) => !new URL(String(url)).searchParams.has('alt')) + ).toBe(true) + }) + + it('includes shortcuts in type-filtered queries and filters on the current target', async () => { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(json(file({ mimeType: docMime }))) + const page = await drive.listDocuments('token', { fileType: 'documents' }) + expect(page.documents).toHaveLength(1) + expect(urlAt(0).searchParams.get('q')).toContain(`mimeType = '${shortcutMime}'`) + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(json(file())) + expect((await drive.listDocuments('token', { fileType: 'documents' })).documents).toEqual([]) + }) + + it.each(['application/vnd.google-apps.folder', shortcutMime, 'application/vnd.google-apps.form'])( + 'does not download unsupported native %s with a PDF filename', + async (mimeType) => { + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce(json(file({ mimeType }))) + expect(await drive.getDocument('token', {}, 'shortcut')).toMatchObject({ + skippedExistingDisposition: 'replace', + skippedReason: 'File is no longer an indexable document', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + } + ) + + it.each([ + { targetId: 'shortcut' }, + { targetId: 'other', targetResourceKey: 'bad\r\nheader' }, + { targetId: '' }, + null, + ])( + 'rejects malformed or cyclic target metadata before requesting it', + async (shortcutDetails) => { + fetchMock.mockResolvedValueOnce(json(shortcut({ shortcutDetails }))) + await expect(drive.getDocument('token', {}, 'shortcut')).rejects.toThrow(/malformed|cyclic/) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) + + it.each([ + ['notFound', 404], + ['insufficientFilePermissions', 403], + ] as const)( + 'withdraws member visibility and clears unavailable target content for %s', + async (reason, status) => { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(denied(reason, status)) + expect( + (await drive.listDocuments('token', {}, undefined, { perMemberListing: true })).documents + ).toEqual([]) + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce(denied(reason, status)) + expect(await drive.getDocument('token', {}, 'shortcut')).toMatchObject({ + skippedExistingDisposition: 'replace', + acl: [], + skippedReason: expect.stringContaining('Shortcut target is unavailable'), + }) + } + ) + + it('keeps other source documents progressing when target metadata fails', async () => { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut(), file({ id: 'ordinary' })] })) + .mockResolvedValueOnce(denied('invalid', 400)) + const page = await drive.listDocuments('token', {}) + expect(page.documents.map((doc) => doc.externalId)).toEqual(['shortcut', 'ordinary']) + expect(page.documents[0]).toMatchObject({ contentDeferred: true, acl: [] }) + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(denied('invalid', 400)) + await expect( + drive.listDocuments('token', {}, undefined, { perMemberListing: true }) + ).rejects.toBeInstanceOf(GoogleDriveApiError) + }) + + it('requires both shortcut and target permissions when mirroring ACLs', async () => { + const alias = shortcut({ + permissions: [{ type: 'user', emailAddress: 'alice@fixture.test', role: 'reader' }], + }) + const target = file({ + permissions: [{ type: 'group', emailAddress: 'team@fixture.test', role: 'reader' }], + }) + fetchMock.mockResolvedValueOnce(json({ files: [alias] })).mockResolvedValueOnce(json(target)) + const page = await drive.listDocuments( + 'token', + { adminEmail: 'admin@fixture.test' }, + undefined, + { mirrorsSourceAcls: true } + ) + expect(page.documents[0].acl).toEqual({ + acl: ['u:alice@fixture.test'], + requirements: [['g:google-drive:fixture.test:team@fixture.test']], + }) + }) + + it('preserves the target restriction through the separate ACL lookup hook', async () => { + const alias = shortcut({ + permissions: [{ type: 'user', emailAddress: 'alice@fixture.test', role: 'reader' }], + }) + const target = file({ + permissions: [{ type: 'user', emailAddress: 'bob@fixture.test', role: 'reader' }], + }) + fetchMock.mockResolvedValueOnce(json({ files: [alias] })).mockResolvedValueOnce(json(target)) + const page = await drive.listDocuments('token', {}) + expect(page.documents[0].acl).toBeUndefined() + fetchMock.mockResolvedValueOnce(json(alias)).mockResolvedValueOnce(json(target)) + const acls = await drive.getDocumentAcls!( + 'token', + { adminEmail: 'admin@fixture.test' }, + page.documents, + { mirrorsSourceAcls: true } + ) + expect(acls.shortcut).toEqual({ + acl: ['u:alice@fixture.test'], + requirements: [['u:bob@fixture.test']], + }) + fetchMock.mockResolvedValueOnce(json(alias)).mockResolvedValueOnce(denied()) + expect( + await drive.getDocumentAcls!('token', { adminEmail: 'admin@fixture.test' }, page.documents, { + mirrorsSourceAcls: true, + }) + ).toEqual({ shortcut: [] }) + }) + + it('fetches target permissions with its resource key and fails closed on permission lookup errors', async () => { + fetchMock + .mockResolvedValueOnce( + json({ + files: [ + shortcut({ + permissions: [{ type: 'anyone', role: 'reader', allowFileDiscovery: true }], + shortcutDetails: { targetId: 'target', targetResourceKey: 'key' }, + }), + ], + }) + ) + .mockResolvedValueOnce(json(file())) + .mockResolvedValueOnce(denied('insufficientFilePermissions', 403)) + const page = await drive.listDocuments( + 'token', + { adminEmail: 'admin@fixture.test', openSharing: 'anyone' }, + undefined, + { mirrorsSourceAcls: true } + ) + expect(page.documents[0].acl).toEqual([]) + expect(urlAt(2).pathname).toBe('/drive/v3/files/target/permissions') + expect(new Headers(fetchMock.mock.calls[2][1].headers).get('X-Goog-Drive-Resource-Keys')).toBe( + 'target/key' + ) + }) + + it('checks target size at listing and caps target bytes while downloading', async () => { + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()] })) + .mockResolvedValueOnce(json(file({ size: String(CONNECTOR_MAX_FILE_BYTES + 1) }))) + expect((await drive.listDocuments('token', {})).documents[0].skippedReason).toContain('exceeds') + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce(json(file())) + .mockResolvedValueOnce( + new Response('small', { + headers: { 'Content-Length': String(CONNECTOR_MAX_FILE_BYTES + 1) }, + }) + ) + expect((await drive.getDocument('token', {}, 'shortcut'))?.skippedReason).toContain('exceeds') + }) + + it('caps metadata bytes before parsing', async () => { + fetchMock + .mockResolvedValueOnce(json(shortcut())) + .mockResolvedValueOnce( + new Response('{}', { headers: { 'Content-Length': String(1024 * 1024 + 1) } }) + ) + await expect(drive.getDocument('token', {}, 'shortcut')).rejects.toThrow('metadata exceeded') + }) + + it('bounds shortcut metadata concurrency to eight without fetching ordinary files', async () => { + let active = 0 + let peak = 0 + fetchMock.mockImplementation(async (input: string) => { + const url = new URL(input) + if (url.pathname.endsWith('/files')) + return json({ + files: Array.from({ length: 30 }, (_, index) => shortcut({ id: `alias-${index}` })), + }) + active++ + peak = Math.max(peak, active) + await Promise.resolve() + active-- + return json(file()) + }) + expect((await drive.listDocuments('token', {})).documents).toHaveLength(30) + expect(peak).toBeLessThanOrEqual(8) + }) + + it('durably sweeps shortcuts after an empty change feed to find target updates and revocations', async () => { + fetchMock.mockResolvedValueOnce(json({ changes: [], newStartPageToken: 'resume' })) + const changes = await drive.listChanges!('token', {}, 'start') + expect(changes.hasMore).toBe(true) + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut()], nextPageToken: 'page-two' })) + .mockResolvedValueOnce(json(file())) + const first = await drive.listChanges!('token', {}, changes.nextCursor!) + expect(first).toMatchObject({ + hasMore: true, + changes: [{ kind: 'upsert', externalId: 'shortcut' }], + }) + expect(urlAt(1).searchParams.get('q')).toBe(`trashed = false and mimeType = '${shortcutMime}'`) + fetchMock + .mockResolvedValueOnce(json({ files: [shortcut({ id: 'revoked' })] })) + .mockResolvedValueOnce(denied()) + const second = await drive.listChanges!('token', {}, first.nextCursor!) + expect(second).toEqual({ + changes: [{ kind: 'removed', externalId: 'revoked' }], + hasMore: false, + nextCursor: 'resume', + }) + expect(urlAt(3).searchParams.get('pageToken')).toBe('page-two') + }) + + it('rejects corrupt shortcut cursors and incomplete searches without advancing the feed', async () => { + await expect(drive.listChanges!('token', {}, 'gdrive-shortcuts:v1:invalid')).rejects.toThrow( + 'must restart' + ) + expect(fetchMock).not.toHaveBeenCalled() + fetchMock.mockResolvedValueOnce(json({ changes: [], newStartPageToken: 'resume' })) + const page = await drive.listChanges!('token', {}, 'start') + fetchMock.mockResolvedValueOnce(json({ files: [], incompleteSearch: true })) + await expect(drive.listChanges!('token', {}, page.nextCursor!)).rejects.toThrow('incomplete') + }) +}) diff --git a/apps/sim/connectors/source-error.ts b/apps/sim/connectors/source-error.ts new file mode 100644 index 00000000000..dd4745021d5 --- /dev/null +++ b/apps/sim/connectors/source-error.ts @@ -0,0 +1,19 @@ +/** Safe provider-owned classification, independent of HTTP status or free-form messages. */ +export type ConnectorSourceFailureCategory = + | 'authorization' + | 'source_unavailable' + | 'request_rejected' + | 'rate_limit' + | 'provider_unavailable' + +/** Providers classify their structured reasons here; shared diagnostics own user-facing text. */ +export class ConnectorSourceError extends Error { + constructor( + message: string, + readonly status: number, + readonly category?: ConnectorSourceFailureCategory + ) { + super(message) + this.name = 'ConnectorSourceError' + } +} diff --git a/apps/sim/lib/knowledge/__integration__/google-drive-shortcuts.integration.ts b/apps/sim/lib/knowledge/__integration__/google-drive-shortcuts.integration.ts new file mode 100644 index 00000000000..11a8836ba91 --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/google-drive-shortcuts.integration.ts @@ -0,0 +1,359 @@ +/** Real sync workers, PostgreSQL, file storage, PDF parsing, indexing, member observations and application authorization; Drive and embedding responses are synthetic. */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db } from '@sim/db' +import { + document, + embedding, + knowledgeConnector, + knowledgeConnectorMember, + organization, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { PDFDocument, StandardFonts } from 'pdf-lib' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ storageRoot: '', embeddingCalls: 0 })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixture.storageRoot + }, +})) +vi.mock('@/lib/embeddings', async () => ({ + ...(await import('@/lib/embeddings/client')), + assertKnowledgeEmbeddingCapacity: async () => {}, + embedKnowledge: async (texts: string[]) => { + fixture.embeddingCalls++ + return { + embeddings: texts.map(() => [1, ...Array(1535).fill(0)]), + totalTokens: texts.length, + billableTokens: 0, + isBYOK: true, + modelName: 'text-embedding-3-small', + pricingId: 'text-embedding-3-small', + } + }, +})) + +import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, + seedKnowledgeMemberFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks' +import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' +import { searchKnowledge } from '@/lib/knowledge/application/search' +import * as connectorTokens from '@/lib/knowledge/connectors/access-token' +import * as memberAccess from '@/lib/knowledge/connectors/member-access' +import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine' +import { executeSync } from '@/lib/knowledge/connectors/sync-engine' +import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' + +const json = (body: unknown) => Response.json(body) +const denied = () => Response.json({ error: { errors: [{ reason: 'notFound' }] } }, { status: 404 }) + +describe('Drive shortcuts through indexing and search', () => { + const ids = createKnowledgeAclFixtureIds() + let billing: Awaited> + let pdfBytes: Buffer + let revision = 1 + let targetMissing = false + let denyBob = false + const aliasPresent = true + let permittedTargetUser: string + let targetDownloads = 0 + let aliasDownloads = 0 + let enrolled: Awaited> + const principal = (userId: string) => ({ + kind: 'session' as const, + userId, + sessionId: 'shortcut-fixture', + }) + const permission = (userId: string) => ({ + type: 'user', + emailAddress: `${userId}@fixture.test`, + role: 'reader', + }) + const alias = () => ({ + id: 'shortcut', + name: 'Alias.pdf', + mimeType: 'application/vnd.google-apps.shortcut', + modifiedTime: '2026-01-01T00:00:00Z', + parents: ['root'], + shortcutDetails: { + targetId: 'target', + targetMimeType: 'application/pdf', + targetResourceKey: 'fixture-key', + }, + permissions: [permission(ids.aliceId), permission(ids.bobId)], + }) + const target = () => ({ + id: 'target', + name: 'Current.pdf', + mimeType: 'application/pdf', + modifiedTime: `2026-01-0${revision}T00:00:00Z`, + size: String(pdfBytes.length), + permissions: [permission(permittedTargetUser)], + }) + + async function setPdf(text: string) { + const pdf = await PDFDocument.create() + const font = await pdf.embedFont(StandardFonts.Helvetica) + pdf.addPage().drawText(text, { x: 20, y: 500, size: 12, font }) + pdfBytes = Buffer.from(await pdf.save()) + } + async function providerFetch(input: string | URL | Request, init?: RequestInit) { + const url = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + ) + const headers = new Headers(init?.headers) + if (url.hostname === 'admin.googleapis.com') { + if (url.pathname.endsWith('/groups')) return json({ groups: [] }) + if (url.pathname.endsWith('/domains')) return json({ domains: [] }) + } + if (url.hostname !== 'www.googleapis.com') throw new Error('Unexpected fixture provider') + if (url.pathname.endsWith('/changes/startPageToken')) return json({ startPageToken: 'start' }) + if (url.pathname.endsWith('/changes')) return json({ changes: [], newStartPageToken: 'resume' }) + if (url.pathname.endsWith('/files')) return json({ files: aliasPresent ? [alias()] : [] }) + if (url.pathname.endsWith('/files/shortcut')) { + if (url.searchParams.get('alt') === 'media') { + aliasDownloads++ + return denied() + } + return aliasPresent ? json(alias()) : denied() + } + if (url.pathname.endsWith('/files/target')) { + expect(headers.get('X-Goog-Drive-Resource-Keys')).toBe('target/fixture-key') + if (targetMissing || (denyBob && headers.get('Authorization') === `Bearer ${ids.bobId}`)) + return denied() + if (url.searchParams.get('alt') === 'media') { + targetDownloads++ + return new Response(new Uint8Array(pdfBytes)) + } + return json(target()) + } + throw new Error('Unexpected fixture Drive endpoint') + } + async function sync() { + const result = await executeSync(ids.connectorId, { + fullSync: true, + billingAttribution: billing, + }) + expect(result.error).toBeUndefined() + expect(result.docsFailed).toBe(0) + return result + } + async function row(connectorId = ids.connectorId) { + const [value] = await db + .select() + .from(document) + .where(and(eq(document.connectorId, connectorId), eq(document.externalId, 'shortcut'))) + expect(value).toBeDefined() + return value! + } + async function chunks(documentId: string, userId = ids.aliceId) { + const result = await listKnowledgeChunks.execute({ + principal: principal(userId), + input: { knowledgeBaseId: ids.knowledgeBaseId, documentId }, + }) + return result.chunks.map((chunk) => chunk.content).join('\n') + } + async function search(userId: string) { + const result = await searchKnowledge.execute({ + principal: principal(userId), + input: { + workspaceId: ids.workspaceId, + knowledgeBaseIds: [ids.knowledgeBaseId], + query: 'Orion', + searchMode: 'hybrid', + topK: 10, + }, + }) + return result.results.map((result) => result.documentId) + } + beforeAll(async () => { + fixture.storageRoot = mkdtempSync(path.join(tmpdir(), 'sim-drive-shortcuts-')) + await seedKnowledgeAclFixture(ids) + permittedTargetUser = ids.aliceId + await setPdf('Orion shortcut original content.') + billing = await resolveBillingAttribution({ + actorUserId: ids.aliceId, + workspaceId: ids.workspaceId, + }) + await db + .update(knowledgeConnector) + .set({ + connectorType: 'google_drive', + sourceConfig: { folderId: 'root' }, + accessMode: 'workspace', + status: 'active', + syncLockToken: null, + }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + vi.spyOn(connectorTokens, 'resolveConnectorAccessToken').mockResolvedValue({ + accessToken: 'fixture-admin', + }) + vi.stubGlobal('fetch', providerFetch) + }) + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + await rm(fixture.storageRoot, { recursive: true, force: true }) + vi.restoreAllMocks() + vi.unstubAllGlobals() + await db.$client.end() + }) + + it('recovers a failed shortcut row, indexes the real PDF, skips unchanged bytes, refreshes target-only edits and recovers a missing target', async () => { + const documentId = generateId() + await db.insert(document).values({ + id: documentId, + knowledgeBaseId: ids.knowledgeBaseId, + connectorId: ids.connectorId, + externalId: 'shortcut', + filename: 'Alias.pdf', + mimeType: 'text/plain', + fileUrl: '', + fileSize: 0, + processingStatus: 'failed', + processingError: 'Synthetic previous download failure', + }) + expect((await sync()).docsUpdated).toBe(1) + const original = await row() + expect(original.id).toBe(documentId) + expect(original.processingStatus).toBe('completed') + expect(await chunks(documentId)).toContain('Orion shortcut original content') + expect(await search(ids.aliceId)).toContain(documentId) + expect( + await downloadFileFromUrl(original.fileUrl, { userId: ids.aliceId, knowledgeAccess: 'user' }) + ).toEqual(pdfBytes) + const downloaded = targetDownloads + const embedded = fixture.embeddingCalls + expect((await sync()).docsUnchanged).toBe(1) + expect(targetDownloads).toBe(downloaded) + expect(fixture.embeddingCalls).toBe(embedded) + + revision++ + await setPdf('Orion shortcut revised content.') + expect((await sync()).docsUpdated).toBe(1) + expect(await chunks(documentId)).toContain('Orion shortcut revised content') + expect((await row()).contentHash).not.toBe(original.contentHash) + targetMissing = true + await sync() + expect((await row()).processingStatus).toBe('failed') + expect(await search(ids.aliceId)).not.toContain(documentId) + expect(await db.select().from(embedding).where(eq(embedding.documentId, documentId))).toEqual( + [] + ) + targetMissing = false + await sync() + expect((await row()).processingStatus).toBe('completed') + expect(await chunks(documentId)).toContain('Orion shortcut revised content') + expect(aliasDownloads).toBe(0) + }, 60000) + + it('persists shortcut and target ACL intersection and applies target-only permission changes without reembedding', async () => { + await db + .update(knowledgeConnector) + .set({ + accessMode: 'admin', + sourceConfig: { folderId: 'root', adminEmail: 'admin@fixture.test' }, + }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + const indexedBefore = await row() + const priorEmbeddings = await db + .select({ id: embedding.id }) + .from(embedding) + .where(eq(embedding.documentId, indexedBefore.id)) + .orderBy(embedding.id) + const downloaded = targetDownloads + await sync() + const indexed = await row() + expect(indexed.aclRequirements).toHaveLength(2) + expect(indexed.aclRequirements).toEqual( + expect.arrayContaining([ + [`u:${ids.aliceId}@fixture.test`, `u:${ids.bobId}@fixture.test`].sort(), + [`u:${ids.aliceId}@fixture.test`], + ]) + ) + expect(await search(ids.aliceId)).toContain(indexed.id) + expect(await search(ids.bobId)).not.toContain(indexed.id) + await expect( + readKnowledgeDocument.execute({ + principal: principal(ids.bobId), + input: { knowledgeBaseId: ids.knowledgeBaseId, documentId: indexed.id }, + }) + ).rejects.toThrow() + permittedTargetUser = ids.bobId + await sync() + expect(await search(ids.aliceId)).not.toContain(indexed.id) + expect(await search(ids.bobId)).toContain(indexed.id) + expect( + await db + .select({ id: embedding.id }) + .from(embedding) + .where(eq(embedding.documentId, indexed.id)) + .orderBy(embedding.id) + ).toEqual(priorEmbeddings) + expect(targetDownloads).toBe(downloaded) + }, 60000) + + it('revokes and restores member search access when only target access changes and the provider feed is empty', async () => { + enrolled = await seedKnowledgeMemberFixture(ids) + await db + .update(knowledgeConnector) + .set({ memberSyncStatus: 'idle', memberSyncLockToken: null }) + .where(eq(knowledgeConnector.id, enrolled.connectorId)) + vi.spyOn(memberAccess, 'mintKnowledgeConnectorMemberToken').mockImplementation( + async ({ credentialId }) => ({ + accessToken: enrolled.members.find((member) => member.credentialId === credentialId)! + .userId, + refreshed: false, + }) + ) + await memberAccess.grantKnowledgeConnectorCredentialAccess( + { + workspaceId: ids.workspaceId, + connectorId: enrolled.connectorId, + credentialGroupId: enrolled.groupId, + credentialGroupOptionId: enrolled.optionId, + }, + ids.aliceId + ) + const syncMembers = async () => { + await db + .update(knowledgeConnectorMember) + .set({ nextAttemptAt: new Date(0) }) + .where(eq(knowledgeConnectorMember.connectorId, enrolled.connectorId)) + const result = await executeMemberSync(enrolled.connectorId, { billingAttribution: billing }) + expect(result.error).toBeUndefined() + return result + } + await syncMembers() + const indexed = await row(enrolled.connectorId) + expect(indexed.processingStatus).toBe('completed') + expect(await search(ids.bobId)).toContain(indexed.id) + const downloaded = targetDownloads + denyBob = true + await syncMembers() + expect(await search(ids.bobId)).not.toContain(indexed.id) + expect(await search(ids.aliceId)).toContain(indexed.id) + expect(targetDownloads).toBe(downloaded) + denyBob = false + await syncMembers() + expect(await search(ids.bobId)).toContain(indexed.id) + expect(targetDownloads).toBe(downloaded) + revision++ + await setPdf('Orion member shortcut target edit.') + await syncMembers() + expect(await chunks(indexed.id)).toContain('Orion member shortcut target edit') + expect(targetDownloads).toBe(downloaded + 1) + }, 60000) +}) diff --git a/apps/sim/lib/knowledge/connectors/connector-error.test.ts b/apps/sim/lib/knowledge/connectors/connector-error.test.ts index 843395c53a1..a1d52f97540 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.test.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.test.ts @@ -2,6 +2,7 @@ import { DrizzleQueryError } from 'drizzle-orm/errors' import { describe, expect, it } from 'vitest' import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' +import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors' describe('connector failure diagnostics', () => { it('retains the SQLSTATE while discarding SQL, bound values and driver detail', () => { @@ -68,6 +69,22 @@ describe('connector failure diagnostics', () => { expect(JSON.stringify(diagnostic)).not.toContain('private') }) + it.each([ + ['fileNotDownloadable', 'request_rejected'], + ['fileNotExportable', 'request_rejected'], + ['exportSizeLimitExceeded', 'request_rejected'], + ['domainPolicy', 'request_rejected'], + ['userRateLimitExceeded', 'rate_limit'], + ['dailyLimitExceeded', 'rate_limit'], + ['insufficientFilePermissions', 'authorization'], + ])('preserves provider classification for HTTP 403 %s', (reason, category) => { + const error = new Error('private wrapper', { cause: new GoogleDriveApiError(403, [reason]) }) + expect(getConnectorFailureDiagnostic(error)).toMatchObject({ status: 403, category }) + expect(JSON.stringify(getConnectorFailureDiagnostic(error))).not.toContain('private') + if (category !== 'authorization') + expect(getConnectorFailureDiagnostic(error)?.message).not.toContain('access was denied') + }) + it('does not infer status or permanence from a free-form message', () => { expect(getConnectorFailureDiagnostic(new Error('HTTP 403 permission denied'))).toBeNull() expect( diff --git a/apps/sim/lib/knowledge/connectors/connector-error.ts b/apps/sim/lib/knowledge/connectors/connector-error.ts index 97e0623073e..7f8e4ac5179 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.ts @@ -1,15 +1,12 @@ import { findCause, getPostgresErrorCode } from '@sim/utils/errors' import { DrizzleQueryError } from 'drizzle-orm/errors' +import { + ConnectorSourceError, + type ConnectorSourceFailureCategory, +} from '@/connectors/source-error' export interface ConnectorFailureDiagnostic { - category: - | 'database' - | 'authorization' - | 'source_unavailable' - | 'request_rejected' - | 'rate_limit' - | 'provider_unavailable' - | 'transport' + category: 'database' | ConnectorSourceFailureCategory | 'transport' message: string status?: number code?: string @@ -74,29 +71,29 @@ export function getConnectorFailureDiagnostic(error: unknown): ConnectorFailureD ) if (!httpError) return null const { status } = httpError - if (status === 401 || status === 403) { + const category = httpError instanceof ConnectorSourceError ? httpError.category : undefined + if (category === 'authorization' || (!category && (status === 401 || status === 403))) { return { category: 'authorization', status, message: `Source content access was denied (HTTP ${status}). Check the connector account's file access and download permissions.`, } } - if (status === 404 || status === 410) { + if (category === 'source_unavailable' || (!category && (status === 404 || status === 410))) { return { category: 'source_unavailable', status, message: `Source content is unavailable (HTTP ${status}). It may have moved, been removed, or lost sharing access.`, } } - if (status === 429) { + if (category === 'rate_limit' || (!category && status === 429)) { return { category: 'rate_limit', status, - message: - 'Source requests are rate limited (HTTP 429). The connector will retry after backoff.', + message: `Source request quota or rate limit was exceeded (HTTP ${status}). The connector will retry after backoff.`, } } - if (status >= 500 || status === 408) { + if (category === 'provider_unavailable' || (!category && (status >= 500 || status === 408))) { return { category: 'provider_unavailable', status, From 2bfb8b103e8653e18cbbc9a0b805d9d64eac087d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 9 Sep 2026 17:53:03 -0700 Subject: [PATCH 10/30] improvement(search): simplify personal integrations and source setup (#7693) * improvement(settings): remove personal connected accounts page * improvement(search): simplify personal integrations and source setup * fix(search): update source route test fixtures --- .../account/settings/[section]/page.test.tsx | 9 +- .../credential-groups/enrollment-redirect.ts | 19 +- .../api/credential-groups/oauth-callback.ts | 6 +- .../oauth/[provider]/callback/route.test.ts | 16 ++ .../connectors/[connectorId]/enroll/route.ts | 5 +- .../api/knowledge/sim-search/connect/route.ts | 2 +- .../sim-search/sources/route.test.ts | 10 +- .../complete/completion-handoff.test.tsx | 49 ++++ .../complete/completion-handoff.tsx | 26 ++ .../app/credential-groups/complete/page.tsx | 31 +- .../enroll/[token]/page.test.tsx | 10 +- .../credential-groups/enroll/[token]/page.tsx | 7 +- .../organization-page/organization-page.tsx | 45 +-- .../integrations/connect-account-options.tsx | 226 +++++++++++++++ .../disconnect-account-menu.test.tsx | 135 +++++++++ .../integrations/disconnect-account-menu.tsx | 62 ++++ .../integrations/integrations.test.tsx | 208 ++++++++++---- .../integrations/integrations.tsx | 269 +++++------------- .../organization-integrations-settings.tsx | 3 - .../organization-integrations-setup.test.tsx | 35 ++- .../organization-integrations-setup.tsx | 21 +- .../organization-search-status.test.ts | 55 ++++ .../organization-search-status.ts | 20 +- .../[connectorType]/provider-detail.test.tsx | 110 +++++-- .../[connectorType]/provider-detail.tsx | 77 +++-- .../sources/[connectorId]/source-detail.tsx | 2 +- .../components/search-source-row.test.tsx | 4 +- .../search/components/search-source-row.tsx | 13 +- .../settings/account-settings-renderer.tsx | 2 - .../components/settings/navigation.test.ts | 1 - apps/sim/components/settings/navigation.ts | 16 +- .../organization-account-people.test.tsx | 106 ++----- .../personal-organization-accounts.tsx | 132 --------- apps/sim/hooks/queries/kb/connectors.ts | 5 +- .../queries/organization-accounts.test.tsx | 69 +++++ .../hooks/queries/organization-accounts.ts | 65 ++--- apps/sim/hooks/use-member-enrollment.test.tsx | 117 +++++++- apps/sim/hooks/use-member-enrollment.ts | 108 +++++-- .../lib/api/contracts/knowledge/connectors.ts | 20 +- .../lib/credential-groups/oauth-completion.ts | 26 ++ .../lib/credential-groups/oauth-state.test.ts | 2 + apps/sim/lib/credential-groups/oauth-state.ts | 17 +- apps/sim/lib/credential-groups/oauth.test.ts | 8 +- apps/sim/lib/credential-groups/oauth.ts | 7 +- apps/sim/lib/knowledge/api/route-policies.ts | 2 + .../application/connector-access.test.ts | 67 +++++ .../knowledge/application/connector-access.ts | 61 +++- .../organization-search-overview.test.ts | 46 ++- .../organization-search-overview.ts | 20 +- .../application/search-sources.test.ts | 36 ++- .../knowledge/application/search-sources.ts | 22 +- .../knowledge/application/sim-search.test.ts | 7 +- .../lib/knowledge/application/sim-search.ts | 5 +- .../connectors/viewer-source-accounts.test.ts | 98 +++++++ .../connectors/viewer-source-accounts.ts | 97 +++++++ apps/sim/lib/sim-search/connectors.test.ts | 18 ++ apps/sim/lib/sim-search/connectors.ts | 5 + 57 files changed, 1926 insertions(+), 734 deletions(-) create mode 100644 apps/sim/app/credential-groups/complete/completion-handoff.test.tsx create mode 100644 apps/sim/app/credential-groups/complete/completion-handoff.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx create mode 100644 apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts delete mode 100644 apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx create mode 100644 apps/sim/lib/credential-groups/oauth-completion.ts create mode 100644 apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts create mode 100644 apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts diff --git a/apps/sim/app/account/settings/[section]/page.test.tsx b/apps/sim/app/account/settings/[section]/page.test.tsx index 838297805e3..bd2226565d6 100644 --- a/apps/sim/app/account/settings/[section]/page.test.tsx +++ b/apps/sim/app/account/settings/[section]/page.test.tsx @@ -52,7 +52,10 @@ describe('account settings legacy links', () => { ) }) - it('still rejects unknown sections', async () => { - await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') - }) + it.each(['unknown', 'connected-accounts'])( + 'rejects unavailable sections: %s', + async (section) => { + await expect(AccountSettingsSectionPage(pageProps(section))).rejects.toThrow('NEXT_NOT_FOUND') + } + ) }) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index d2768f19ee7..755a68094b2 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' const NO_STORE_REDIRECT_HEADERS = { 'Cache-Control': 'no-store', @@ -20,23 +21,17 @@ export function createCredentialGroupEnrollmentRedirect( }) } -export type CredentialGroupOAuthFailure = - | 'expired' - | 'denied' - | 'account_mismatch' - | 'permissions_required' - | 'configuration_changed' - | 'rate_limited' - | 'unavailable' - | 'failed' - export function createCredentialGroupCompletionRedirect( - oauth?: CredentialGroupOAuthFailure + oauth?: CredentialGroupOAuthFailure, + completionId?: string ): NextResponse { + const query = new URLSearchParams() + if (oauth) query.set('oauth', oauth) + if (completionId) query.set('completionId', completionId) return new NextResponse(null, { status: 303, headers: { - Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`, + Location: `/credential-groups/complete${query.size ? `?${query}` : ''}`, ...NO_STORE_REDIRECT_HEADERS, }, }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 399eb222982..aa565c8be62 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -5,6 +5,7 @@ import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/cred import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, @@ -12,7 +13,6 @@ import { } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { - type CredentialGroupOAuthFailure, createCredentialGroupCompletionRedirect, createCredentialGroupEnrollmentRedirect, } from '@/app/api/credential-groups/enrollment-redirect' @@ -54,7 +54,7 @@ export async function handleCredentialGroupOAuthCallback({ : {} const failureRedirect = (oauth: CredentialGroupOAuthFailure) => attempt.completionRedirect - ? createCredentialGroupCompletionRedirect(oauth) + ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { return failureRedirect('rate_limited') @@ -74,7 +74,7 @@ export async function handleCredentialGroupOAuthCallback({ request, }) return attempt.completionRedirect - ? createCredentialGroupCompletionRedirect() + ? createCredentialGroupCompletionRedirect(undefined, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, connected: attempt.optionId, diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 7c2b4a9edb9..508dc05267a 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -56,6 +56,22 @@ function request(query: string) { } describe('credential group OAuth callback', () => { + it.each([ + ['code=code-1', undefined], + ['error=access_denied', 'denied'], + ])( + 'correlates direct OAuth completion without returning to enrollment: %s', + async (query, failure) => { + const completionId = '550e8400-e29b-41d4-a716-446655440000' + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true, completionId }) + const response = await GET(request(`state=state-1&${query}`), context) + const location = new URL(response.headers.get('location')!, 'https://sim.test') + expect(response.status).toBe(303) + expect(location.pathname).toBe('/credential-groups/complete') + expect(location.searchParams.get('completionId')).toBe(completionId) + expect(location.searchParams.get('oauth')).toBe(failure ?? null) + } + ) beforeEach(() => { vi.clearAllMocks() mocks.rateLimit.mockResolvedValue(null) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts index da3cc91c176..ddedaec89c6 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts @@ -16,10 +16,11 @@ export const POST = defineInternalJsonRoute({ reason: 'A member connecting their own account by hand; each call only re-issues their own invitation', }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params }) => ({ + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, + mapInput: ({ params, query }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, + oauthCompletionId: query.oauthCompletionId, }), useCase: startKnowledgeConnectorMemberEnrollment, present: ({ url }) => ({ success: true as const, data: { url } }), diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index 9f2319ee6d3..521b6b570ef 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -13,7 +13,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: knowledgeOperations.simSearchConnect, rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, mapInput: ({ body }) => body, useCase: connectSimSearchConnector, present: (result) => ({ success: true as const, data: result }), diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts index 43251e1748c..44ee57d8279 100644 --- a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OrganizationSearchProviderSummary, + SearchSourceSummary, +} from '@/lib/api/contracts/knowledge/connectors' const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -58,9 +62,10 @@ const source = { viewerDocumentCount: 0, viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: false, viewerMembership: null, -} +} satisfies SearchSourceSummary beforeEach(() => { vi.clearAllMocks() @@ -266,8 +271,9 @@ describe('organization administration overview boundary', () => { sourceCount: 1, approved: true, status: 'waiting_for_connections', + issue: null, isSyncing: false, - } + } satisfies OrganizationSearchProviderSummary mocks.adminOverview.mockResolvedValue({ providers: [{ ...provider, privateAccount: 'private' }], documentNames: ['private'], diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx new file mode 100644 index 00000000000..f139057a076 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('credential group OAuth completion', () => { + it.each([undefined, 'denied', 'configuration_changed'] as const)( + 'publishes %s to only its initiating tab and closes', + (failure) => { + const postMessage = vi.fn() + const closeChannel = vi.fn() + const names: string[] = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = postMessage + close = closeChannel + constructor(name: string) { + names.push(name) + } + } + ) + const closeWindow = vi.spyOn(window, 'close').mockImplementation(() => {}) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + const completionId = '550e8400-e29b-41d4-a716-446655440000' + try { + act(() => + root.render( + + ) + ) + expect(names).toEqual([`sim:credential-group-oauth:${completionId}`]) + expect(postMessage).toHaveBeenCalledExactlyOnceWith(failure ?? 'connected') + expect(closeChannel).toHaveBeenCalledOnce() + expect(closeWindow).toHaveBeenCalledOnce() + } finally { + act(() => root.unmount()) + } + } + ) +}) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx new file mode 100644 index 00000000000..07f8d440126 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' +import { + type CredentialGroupOAuthFailure, + credentialGroupOAuthCompletionChannel, +} from '@/lib/credential-groups/oauth-completion' + +interface CredentialGroupCompletionHandoffProps { + completionId: string + failure?: CredentialGroupOAuthFailure +} + +/** Notifies the originating tab even when provider navigation has removed window.opener. */ +export function CredentialGroupCompletionHandoff({ + completionId, + failure, +}: CredentialGroupCompletionHandoffProps) { + useEffect(() => { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + channel.postMessage(failure ?? 'connected') + channel.close() + window.close() + }, [completionId, failure]) + return null +} diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 86dbcae5ee3..07edac4b434 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -1,36 +1,33 @@ import { ChipLink } from '@sim/emcn' +import { isValidUuid } from '@sim/utils/id' import type { Metadata } from 'next' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { AuthHeader, AuthShell } from '@/app/(auth)/components' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' export const metadata: Metadata = { title: 'Accounts connected', robots: { index: false, follow: false }, } -const OAUTH_FAILURE_MESSAGES = { - expired: 'This connection attempt expired. Open Sim and start connecting your account again.', - denied: 'Authorization was canceled. Open Sim to try again.', - account_mismatch: 'Choose the account matching your Sim email address.', - permissions_required: 'All requested permissions are required to connect this account.', - configuration_changed: 'The connection settings changed. Open Sim to try again.', - rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', - unavailable: 'This connection is unavailable. Open Sim to try again.', - failed: 'Account authorization did not complete. Open Sim to try again.', -} as const - export default async function CredentialGroupCompletePage({ searchParams, }: { - searchParams: Promise<{ oauth?: string | string[] }> + searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }> }) { - const { oauth } = await searchParams - const error = - typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth) - ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES] - : undefined + const { oauth, completionId } = await searchParams + const failure = + oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed' + const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined return ( + {typeof completionId === 'string' && isValidUuid(completionId) && ( + + )} { expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} }) }) - it('keeps account-settings reconnect focused and returns to account settings', async () => { + it('keeps existing account reconnect links focused and returns to Sim', async () => { await render({ returnTo: 'accounts', optionId: 'site-two' }) expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([ '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=accounts', @@ -156,9 +156,9 @@ describe('focused Search enrollment', () => { expect(document.querySelector('form')).toBeNull() expect( Array.from(document.querySelectorAll('a')) - .find((link) => link.textContent === 'Your connected accounts') + .find((link) => link.textContent === 'Open Sim') ?.getAttribute('href') - ).toBe('/account/settings/connected-accounts') + ).toBe('/home') }) it('lets an account owner deliberately reconnect an active grant before reporting completion', async () => { @@ -189,12 +189,12 @@ describe('focused Search enrollment', () => { }) mocks.read.mockResolvedValue({ enrollment, canSearch }) await render({ returnTo: 'search', optionId: 'site-two' }) - const label = canSearch ? 'Return to Search' : 'Your connected accounts' + const label = canSearch ? 'Return to Search' : 'Open Sim' expect( Array.from(document.querySelectorAll('a')) .find((link) => link.textContent === label) ?.getAttribute('href') - ).toBe(canSearch ? '/o/canonical-org/search' : '/account/settings/connected-accounts') + ).toBe(canSearch ? '/o/canonical-org/search' : '/home') } ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 57febd8e801..f5504cef26a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -3,7 +3,6 @@ import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' import { redirect } from 'next/navigation' -import { getAccountSettingsHref } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' import type { ResourceOwner } from '@/lib/core/resource-scope' @@ -184,10 +183,8 @@ export default async function CredentialGroupEnrollmentPage({ const canReturnToSearch = returnToSearch && ('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId) - const returnHref = canReturnToSearch - ? searchReturnPath(principal) - : getAccountSettingsHref('connected-accounts') - const returnLabel = canReturnToSearch ? 'Return to Search' : 'Your connected accounts' + const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH + const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim' if (!enrollment) return diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx index f3f43caf421..7562bc5cd02 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx @@ -34,6 +34,9 @@ interface OrganizationPageProps { tabs?: readonly OrganizationPageTab[] /** The page's primary action, a chip. Omit for a page without one. */ action?: ReactNode + /** Keep the search field visible on pages whose main content is a searchable list. */ + searchMode?: 'collapsible' | 'expanded' + searchPlaceholder?: string children?: ReactNode } @@ -53,6 +56,8 @@ export function OrganizationPage({ description, tabs, action, + searchMode = 'collapsible', + searchPlaceholder = 'Search', children, }: OrganizationPageProps) { const scrollContainerRef = useRef(null) @@ -71,7 +76,7 @@ export function OrganizationPage({ * viewer opened and has not dismissed. */ const [searchOpened, setSearchOpened] = useState(false) - const searchOpen = searchOpened || search.length > 0 + const searchOpen = searchMode === 'expanded' || searchOpened || search.length > 0 const closeSearch = () => { setSearch('') @@ -99,7 +104,8 @@ export function OrganizationPage({ ref={tabsRef} className={cn( scrollFadeXClass, - 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' + 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + searchMode === 'expanded' && !tabs?.length && 'hidden' )} {...scrollFadeAttributes(tabEdges)} > @@ -119,32 +125,39 @@ export function OrganizationPage({ ) })}
-
+
{searchOpen ? ( setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') closeSearch() }} endAdornment={ - + (searchMode === 'collapsible' || search.length > 0) && ( + + ) } /> ) : ( diff --git a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx new file mode 100644 index 00000000000..aea98ec0b7b --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx @@ -0,0 +1,226 @@ +'use client' + +import { useMemo } from 'react' +import { Chip } from '@sim/emcn' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { + connectorDisplayName, + getConnectorAccessAvailability, + SEARCH_CONNECTORS, +} from '@/lib/sim-search/connectors' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' +import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +interface ConnectAccountOptionsProps { + search?: string + showEmpty?: boolean +} + +/** Approved integrations the viewer can connect to Search with their own account. */ +export function ConnectAccountOptions({ + search = '', + showEmpty = true, +}: ConnectAccountOptionsProps = {}) { + const { organization, searchAccess } = useOrganizationContext() + const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } + const sources = useSearchSources(scope, { search }) + const overview = useSearchSourceOverview(scope) + const integrations = useSearchIntegrations(organization.id) + const availability = usePermissionConfig() + const membershipQueryKeys = useMemo( + () => [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], + [organization.id] + ) + const connectedConnectorIds = useMemo( + () => + new Set( + sources.data + ?.filter((source) => source.viewerMembership === 'connected') + .map((source) => source.connectorId) + ), + [sources.data] + ) + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + }) + const visibleSources = + sources.data?.filter( + (source) => + source.connectionRequired && + source.enabled && + source.approved !== false && + source.availability === 'available' && + source.viewerMembership !== null && + source.viewerMembership !== 'needs_reauth' && + CONNECTABLE_MEMBERSHIPS.has(source.viewerMembership) && + searchAccess.memberScoped && + (source.accessMode === 'members' || searchAccess.sourceMirrored) + ) ?? [] + + const approvedTypes = new Set( + integrations.data + ?.filter((integration) => integration.approved) + .map((integration) => integration.connectorType) + ) + const configuredTypes = new Set( + overview.data?.providers.map((provider) => provider.connectorType) + ) + const sourceChoices = SEARCH_CONNECTORS.filter((connector) => { + if ( + connector.type === 'slack' || + !approvedTypes.has(connector.type) || + !connector.meta.name.toLowerCase().includes(search.toLowerCase()) || + (configuredTypes.has(connector.type) && connector.setupFields.length === 0) + ) + return false + return getConnectorAccessAvailability(connector.meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }).members + }) + const integrationRows = [ + ...sourceChoices.map((connector) => ({ + kind: 'provider' as const, + connector, + name: connector.meta.name, + })), + ...visibleSources.map((source) => ({ + kind: 'source' as const, + source, + name: connectorDisplayName(source.connectorType), + })), + ].sort( + (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) + ) + const failedQuery = + sources.isError && !sources.isFetchNextPageError + ? sources + : overview.isError + ? overview + : integrations.isError + ? integrations + : null + + return ( + <> +
+ {failedQuery ? ( + void failedQuery.refetch()} + variant='inline' + /> + ) : availability.integrationAvailabilityError ? ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + ) : sources.isPending || + overview.isPending || + integrations.isPending || + !availability.isIntegrationAvailabilityReady ? ( + Loading sources… + ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + <> + {integrationRows.map((row) => { + if (row.kind === 'source') { + const { source } = row + return ( + enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ) + } + const { connector } = row + const { type, meta } = connector + const hasSources = configuredTypes.has(type) + return ( + } + title={meta.name} + description={ + hasSources + ? 'Connect a different site or content scope' + : 'Connect your account to search this source' + } + trailing={ + enrollment.connectSearchSource(scope, connector, undefined)} + > + Connect + + } + /> + ) + })} + + + ) : showEmpty ? ( + + {search ? 'No matching integrations.' : 'No integrations are available to connect.'} + + ) : null} + {enrollment.error && ( +

{enrollment.error}

+ )} +
+ {enrollment.setupConnector && ( + + enrollment.connectSource(scope, enrollment.setupConnector!.type, config) + } + /> + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx new file mode 100644 index 00000000000..279513d60a4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx @@ -0,0 +1,135 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ disconnect: vi.fn(), mutate: vi.fn(), reset: vi.fn() })) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useDisconnectPersonalOrganizationAccount: mocks.disconnect, +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ + IntegrationTile: () => null, +})) + +import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' + +const accounts = [{ credentialId: 'my-gmail', displayName: 'me@example.test' }] +const source: SearchSourceSummary = { + knowledgeBaseId: 'kb', + connectorId: 'gmail', + connectorType: 'gmail', + sourceDescription: '', + accessMode: 'members', + availability: 'available', + enabled: true, + isSyncing: true, + lastSyncAt: null, + hasSyncError: false, + viewerDocumentCount: 0, + viewerFailedDocumentCount: 0, + viewerEmailVerified: true, + connectionRequired: true, + viewerMembership: 'connected', + viewerAccounts: accounts, +} + +describe('personal integration disconnect', () => { + let root: Root + let container: HTMLDivElement + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: null, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + async function render(overrides: Partial = {}) { + await act(async () => + root.render( + + } + /> + ) + ) + } + async function openDisconnect() { + const trigger = document.querySelector( + '[aria-label="Gmail account actions"]' + )! + await act(async () => + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const item = document.querySelector('[role="menuitem"]')! + expect(item.textContent).toBe('Disconnect') + expect(item.hasAttribute('data-disabled')).toBe(false) + await act(async () => item.click()) + } + function confirm() { + return Array.from(document.querySelectorAll('[role="dialog"] button')).find( + (button) => button.textContent === 'Disconnect' + )! + } + + it.each([ + ['indexing', {}], + ['failed', { hasSyncError: true }], + ['paused', { enabled: false }], + ['deactivated', { approved: false }], + ['reconnect', { viewerMembership: 'needs_reauth' }], + ['unavailable', { availability: 'unavailable', viewerMembership: null }], + ] as const)('allows disconnect while %s without requiring admin access', async (_, overrides) => { + await render(overrides) + await openDisconnect() + expect(document.body.textContent).toContain('Sim will stop using me@example.test for Search.') + expect(document.body.textContent).not.toContain('workflows') + expect(mocks.mutate).not.toHaveBeenCalled() + expect(confirm().disabled).toBe(false) + await act(async () => confirm().click()) + expect(mocks.mutate).toHaveBeenCalledExactlyOnceWith( + 'my-gmail', + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + }) + + it('shows a failure in the confirmation and keeps it retryable', async () => { + await render() + await openDisconnect() + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: new Error('Could not disconnect. Try again.'), + }) + await render() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Could not disconnect. Try again.' + ) + expect(confirm().disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx new file mode 100644 index 00000000000..6d3166ff616 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' +import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' + +interface DisconnectAccountMenuProps { + organizationId: string + integrationName: string + accounts: ViewerSearchSourceAccount[] +} + +/** Disconnect is independent of provider availability, reconnect state, and indexing activity. */ +export function DisconnectAccountMenu({ + organizationId, + integrationName, + accounts, +}: DisconnectAccountMenuProps) { + const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const [selectedId, setSelectedId] = useState(null) + const selected = accounts.find((account) => account.credentialId === selectedId) + if (!accounts.length) return null + + return ( + <> + ({ + label: accounts.length === 1 ? 'Disconnect' : `Disconnect ${account.displayName}`, + destructive: true, + disabled: disconnect.isPending, + onSelect: () => { + disconnect.reset() + setSelectedId(account.credentialId) + }, + }))} + /> + { + if (!open && !disconnect.isPending) setSelectedId(null) + }} + title={`Disconnect ${integrationName}`} + text={`Sim will stop using ${selected?.displayName ?? integrationName} for Search. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting…', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => { + if (!selected || disconnect.isPending) return + disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + }, + }} + > + {disconnect.error?.message} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index ecdfb02e5ba..edf5d144b50 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ connect: vi.fn(), availability: vi.fn(), refetchAvailability: vi.fn(), + enrollment: vi.fn(), })) vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({ @@ -57,20 +58,25 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({ })) vi.mock('@/hooks/use-member-enrollment', () => ({ CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), - useMemberEnrollment: () => ({ - connect: mocks.connect, - connectSearchSource: mocks.connect, - isAwaiting: () => false, - isPending: false, - error: null, - }), + useMemberEnrollment: (options: unknown) => { + mocks.enrollment(options) + return { + connect: mocks.connect, + connectSearchSource: mocks.connect, + isAwaiting: () => false, + isPending: false, + error: null, + } + }, })) vi.mock('@/hooks/use-oauth-return', () => ({ useDesktopOAuthConnectListener: () => undefined, useOAuthReturnRouter: () => undefined, })) +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' const scope = { kind: 'organization', organizationId: 'organization-a' } as const const memberSource: SearchSourceSummary = { @@ -115,7 +121,11 @@ describe('organization integrations role and source paths', () => { mocks.integrations.mockReturnValue({ data: [], isPending: false }) mocks.availability.mockReturnValue({ integrationAvailability: new Map(), - oauthServiceAvailability: new Map([['google-email', true]]), + oauthServiceAvailability: new Map([ + ['google-email', true], + ['confluence', true], + ['jira', true], + ]), isIntegrationAvailabilityReady: true, integrationAvailabilityError: null, isIntegrationAvailabilityFetching: false, @@ -146,7 +156,7 @@ describe('organization integrations role and source paths', () => { }) async function render() { - await act(async () => root.render()) + await act(async () => root.render()) } function buttons(label: string) { @@ -157,16 +167,16 @@ describe('organization integrations role and source paths', () => { it('uses the actual organization and only asks members to connect identity-dependent sources', async () => { await render() - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: false }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '' }) expect(buttons('Add source')).toHaveLength(0) expect(buttons('Manage')).toHaveLength(0) - expect(buttons('Connect account')).toHaveLength(1) - expect(document.body.textContent).toContain('4 searchable documents') - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Engineering') + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) - it('keeps Slack return actions alongside the standard account and source actions', async () => { + it('keeps Slack return actions alongside personal connection controls', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -177,19 +187,32 @@ describe('organization integrations role and source paths', () => { ) ) - expect(document.body.textContent).toContain('Your accounts') - expect(document.body.textContent).toContain('Manage sources') + expect(document.body.textContent).not.toContain('Your accounts') + expect(document.body.textContent).not.toContain('Manage sources') expect(buttons('slack-return')).toHaveLength(1) }) - it('debounces server search while applying the selected tab immediately', async () => { + it('always requests personal connections even with an old All tab URL', async () => { vi.useFakeTimers() - await render() - mocks.filters.mockReturnValue({ tab: 'mine', search: ' drive ', setSearch: vi.fn() }) - await render() - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: '', mine: true }) + await act(async () => root.render()) + mocks.filters.mockReturnValue({ tab: 'all', search: ' drive ', setSearch: vi.fn() }) + await act(async () => root.render()) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) await act(async () => vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS)) - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: 'drive', mine: true }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'drive', mine: true }) + }) + + it('refreshes organization Accounts after either direct connection flow completes', async () => { + await act(async () => root.render()) + expect(mocks.enrollment.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [options] of mocks.enrollment.mock.calls) { + expect(options).toMatchObject({ + directOAuth: true, + membershipQueryKeys: expect.arrayContaining([ + organizationAccountsKeys.detail(scope.organizationId), + ]), + }) + } }) it('offers an approved integration before any source is configured', async () => { @@ -204,8 +227,8 @@ describe('organization integrations role and source paths', () => { }) await render() expect(document.body.textContent).toContain('Connect your account to search this source') - expect(buttons('Connect account')).toHaveLength(1) - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'gmail' }), @@ -231,8 +254,8 @@ describe('organization integrations role and source paths', () => { isIntegrationAvailabilityReady: true, }) await render() - expect(buttons('Add source')).toHaveLength(1) - await act(async () => buttons('Add source')[0].click()) + expect(buttons('Connect')).toHaveLength(2) + await act(async () => buttons('Connect')[1].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'confluence' }), @@ -261,8 +284,8 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Deactivated by an organization admin') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') }) it('waits for availability before describing approved sources as needing admin setup', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) @@ -278,7 +301,7 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Loading sources') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) it('retries availability failures instead of asking an admin to finish setup', async () => { @@ -300,11 +323,11 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Connection availability failed') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) await act(async () => buttons('Try again')[0].click()) expect(mocks.refetchAvailability).toHaveBeenCalledOnce() }) - it('asks an admin to configure Slack before members can connect an approved source', async () => { + it('hides Slack until its organization setup is ready', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, @@ -315,10 +338,42 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('An admin needs to finish source setup') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Slack') + expect(document.body.textContent).toContain('No integrations are available to connect.') + }) + + it('hides an approved provider when its OAuth configuration is missing', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map([['google-email', false]]), + isIntegrationAvailabilityReady: true, + }) + await render() + expect(document.body.textContent).not.toContain('Gmail') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).toContain('No integrations are available to connect.') }) - it('takes admins directly to unfinished Slack indexing setup', async () => { + + it('offers personal Slack connection once source setup is complete', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, connectorType: 'slack', accessMode: 'admin' }], + isPending: false, + }) + await render() + expect(document.body.textContent).toContain('Slack') + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Finish Slack setup') + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') + }) + it('also hides unfinished Slack setup from admins on this personal surface', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -333,46 +388,73 @@ describe('organization integrations role and source paths', () => { await render() expect( document.querySelector('a[href="/o/organization-a/settings/integrations/providers/slack"]') - ).toHaveTextContent('Finish Slack setup') + ).toBeNull() expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) - it('keeps personal rows consistent for admins and directs management through Sources', async () => { + it('keeps source administration off the personal page for admins', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, searchAccess: { memberScoped: true, sourceMirrored: true }, }) - await render() - expect( - document.querySelector( - 'a[href="/o/organization-a/settings/integrations/sources/member-source"]' - ) - ).toBeNull() - expect( - document.querySelector('a[href="/o/organization-a/settings/integrations"]') - ).toHaveTextContent('Manage sources') - expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).not.toBeNull() - expect(buttons('Add source')).toHaveLength(0) - expect(buttons('Manage')).toHaveLength(0) + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'connected' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).not.toContain('Manage sources') + expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).toBeNull() expect(document.querySelector('[aria-label$="source actions"]')).toBeNull() - expect(buttons('Connect account')).toHaveLength(1) + expect(buttons('Connect')).toHaveLength(0) }) - it('lists only the sources the viewer connected under Mine', async () => { - mocks.filters.mockReturnValue({ tab: 'mine', search: '', setSearch: vi.fn() }) + it('shows ready integrations inline and connects without an intermediate dialog', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) - await render() + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) - expect(document.body.textContent).toContain('You haven’t connected any sources yet.') - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, viewerMembership: 'connected' }], + expect(document.body.textContent).toContain('Gmail') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(buttons('Connect account')).toHaveLength(0) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledWith( + scope, + expect.objectContaining({ type: 'gmail' }), + undefined + ) + }) + + it('filters available providers using the same search as personal connections', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.integrations.mockReturnValue({ + data: [ + { connectorType: 'gmail', approved: true }, + { connectorType: 'jira', approved: true }, + ], isPending: false, }) - await render() + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(document.body.textContent).toContain('Gmail') - expect(document.body.textContent).not.toContain('Engineering') + expect(document.body.textContent).not.toContain('Jira') + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'gmail' }) + }) + + it('lets the viewer reconnect their own expired account from the main page', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'needs_reauth' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).toContain('Your account needs to be reconnected') + await act(async () => buttons('Reconnect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) it('does not offer connection to an unavailable source or setup to a member with no sources', async () => { @@ -382,15 +464,15 @@ describe('organization integrations role and source paths', () => { searchAccess: { memberScoped: false, sourceMirrored: false }, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Not available in this organization') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, isPending: false, }) await render() - expect(document.body.textContent).toContain('Ask an organization admin to get started') + expect(document.body.textContent).toContain('No integrations are available to connect.') expect(buttons('Add source')).toHaveLength(0) }) it('keeps sparse source pages navigable without claiming missing sources or duplicating configured providers', async () => { @@ -402,7 +484,7 @@ describe('organization integrations role and source paths', () => { }) await render() expect(buttons('Load more')).toHaveLength(1) - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) expect(document.body.textContent).not.toContain('hasn’t added any sources') await act(async () => buttons('Load more')[0].click()) expect(fetchNextPage).toHaveBeenCalledOnce() @@ -411,7 +493,7 @@ describe('organization integrations role and source paths', () => { it('retains loaded rows on a next-page failure and retries only that page', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ - data: [centralSource], + data: [{ ...memberSource, sourceDescription: 'Engineering' }], isPending: false, isError: true, isFetchNextPageError: true, diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index 23c0d287e5c..71bb9109950 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -1,69 +1,44 @@ 'use client' import { useMemo } from 'react' -import { Chip, ChipLink } from '@sim/emcn' -import { getAccountSettingsHref } from '@/components/settings/navigation' import type { ResourceScope } from '@/lib/core/resource-scope' -import { organizationRoutes } from '@/lib/navigation/paths' -import { - connectorDisplayName, - getConnectorAccessAvailability, - SEARCH_CONNECTORS, - SEARCH_SOURCE_TYPES, -} from '@/lib/sim-search/connectors' +import { connectorDisplayName } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' -import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSources } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { useDebounce } from '@/hooks/use-debounce' import { useMemberEnrollment } from '@/hooks/use-member-enrollment' import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return' -import { usePermissionConfig } from '@/hooks/use-permission-config' - -/** Every source the organization searches, or only the ones the viewer has connected. */ -const TABS = [ - { id: 'all', label: 'All' }, - { id: 'mine', label: 'Mine' }, -] as const interface OrganizationIntegrationsProps { slackOnboarding?: { token: string; userId: string } } -/** - * Personal connections and approved source scopes available to the organization. - * Administrators can open source management without leaving this journey. - */ +/** The viewer's Search connections and ready integrations they can connect personally. */ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegrationsProps = {}) { useOAuthReturnRouter() useDesktopOAuthConnectListener() - const { organization, searchAccess, viewer } = useOrganizationContext() - const routes = organizationRoutes(organization.id) + const { organization, searchAccess } = useOrganizationContext() const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } - const { tab, search } = useOrganizationPageFilters() + const { search } = useOrganizationPageFilters() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const sources = useSearchSources(scope, { search: sourceSearch, mine: tab === 'mine' }) - const overview = useSearchSourceOverview(scope) - const integrations = useSearchIntegrations(organization.id) - const availability = usePermissionConfig() + const sources = useSearchSources(scope, { search: sourceSearch, mine: true }) const membershipQueryKeys = useMemo( - () => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })], + () => [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], [organization.id] ) const connectedConnectorIds = useMemo( @@ -75,190 +50,74 @@ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegr ), [sources.data] ) - const enrollment = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds }) - const query = search.trim().toLowerCase() - const mineOnly = tab === 'mine' - const visibleSources = sources.data ?? [] - - const approvedTypes = new Set( - integrations.data - ?.filter((integration) => integration.approved) - .map((integration) => integration.connectorType) - ) - const configuredTypes = new Set( - overview.data?.providers.map((provider) => provider.connectorType) - ) - const sourceChoices = mineOnly - ? [] - : SEARCH_SOURCE_TYPES.filter( - ([type, meta]) => - approvedTypes.has(type) && - (!configuredTypes.has(type) || - SEARCH_CONNECTORS.some( - (connector) => connector.type === type && connector.setupFields.length > 0 - )) && - meta.name.toLowerCase().includes(query) - ) - const integrationRows = [ - ...sourceChoices.map(([type, meta]) => ({ - kind: 'provider' as const, - type, - meta, - name: meta.name, - })), - ...visibleSources.map((source) => ({ - kind: 'source' as const, - source, - name: connectorDisplayName(source.connectorType), - })), - ].sort( - (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) - ) - const failedQuery = - sources.isError && !sources.isFetchNextPageError - ? sources - : overview.isError - ? overview - : integrations.isError - ? integrations - : null + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + }) return ( - Your accounts - {viewer.isAdmin && ( - Manage sources - )} - {slackOnboarding && ( - - )} -
+ slackOnboarding && ( + + ) } >
- {failedQuery ? ( + {sources.isError && !sources.isFetchNextPageError ? ( void failedQuery.refetch()} + error={sources.error} + fallback='Could not load your connections' + isRetrying={sources.isFetching} + onRetry={() => void sources.refetch()} variant='inline' /> - ) : availability.integrationAvailabilityError ? ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - ) : sources.isPending || - overview.isPending || - integrations.isPending || - !availability.isIntegrationAvailabilityReady ? ( - Loading sources… - ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + ) : !sources.isPending && (sources.data?.length || sources.hasNextPage) ? ( <> - {integrationRows.map((row) => { - if (row.kind === 'source') { - const { source } = row - return ( - enrollment.connect(source.knowledgeBaseId, source.connectorId)} - /> - ) - } - const { type, meta } = row - const connector = SEARCH_CONNECTORS.find((item) => item.type === type) - const access = getConnectorAccessAvailability( - meta, - availability.integrationAvailability, - { - memberAccessAvailable: searchAccess.memberScoped, - mirroredAccessAvailable: searchAccess.sourceMirrored, - oauthServiceAvailability: availability.oauthServiceAvailability, - isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + {sources.data?.map((source) => ( + + ) : undefined } - ) - const canConnect = connector && type !== 'slack' && access.members - const hasSources = configuredTypes.has(type) - if (hasSources && !canConnect) return null - return ( - } - title={hasSources ? `Add another ${meta.name} source` : meta.name} - description={ - hasSources - ? 'Connect a different site or content scope' - : canConnect - ? 'Connect your account to search this source' - : type === 'slack' && viewer.isAdmin - ? 'Finish setting up Slack indexing to connect accounts' - : 'An admin needs to finish source setup' - } - trailing={ - canConnect ? ( - enrollment.connectSearchSource(scope, connector, undefined)} - > - {hasSources ? 'Add source' : 'Connect account'} - - ) : type === 'slack' && viewer.isAdmin ? ( - Finish Slack setup - ) : undefined - } - /> - ) - })} + available={ + source.accessMode === 'members' + ? searchAccess.memberScoped + : searchAccess.sourceMirrored && + (!source.connectionRequired || searchAccess.memberScoped) + } + waiting={enrollment.isAwaiting(source.connectorId)} + isPending={enrollment.isPending} + onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ))} - ) : ( - - {query - ? 'No matching sources.' - : mineOnly - ? 'You haven’t connected any sources yet.' - : viewer.isAdmin - ? 'Your organization hasn’t added any sources yet. Open Manage sources to get started.' - : 'Your organization hasn’t added any sources yet. Ask an organization admin to get started.'} - - )} + ) : null} {enrollment.error && (

{enrollment.error}

)}
- {enrollment.setupConnector && ( - - enrollment.connectSource(scope, enrollment.setupConnector!.type, config) - } - /> - )} + ) } diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index 4634cb0e4af..f0508c816a1 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -35,9 +35,6 @@ export function OrganizationIntegrationsSettings() { { value: 'people', label: 'People' }, ]} /> - {tab === 'providers' && ( - Allowed in Sim Search - )}
{tab === 'providers' && } {tab === 'people' && ( diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx index c38d53d5c93..2d303cea62f 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx @@ -56,6 +56,7 @@ const providers: OrganizationSearchProviderSummary[] = [ approved: true, sourceCount: 0, status: 'waiting_for_connections', + issue: null, isSyncing: false, }, { @@ -63,6 +64,7 @@ const providers: OrganizationSearchProviderSummary[] = [ approved: false, sourceCount: 2, status: 'paused', + issue: null, isSyncing: false, }, ] @@ -127,9 +129,33 @@ async function click(label: string) { } describe('organization integration management entry', () => { + it('offers Drive account management before anyone has connected', async () => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType: 'google_drive', + approved: true, + sourceCount: 0, + status: 'waiting_for_connections', + issue: null, + isSyncing: false, + }, + ], + }, + isPending: false, + }) + await render() + expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( + 'href', + '/o/org-one/settings/integrations/providers/google_drive' + ) + expect(document.querySelector('a[aria-label="Set up Google Drive"]')).toBeNull() + expect(container.textContent).toContain('Waiting for connections') + }) it('shows the stable catalog with switches and separate setup and management links', async () => { await render() - expect(document.querySelector('a[aria-label="Set up Gmail"]')).toHaveAttribute( + expect(document.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( 'href', '/o/org-one/settings/integrations/providers/gmail' ) @@ -137,8 +163,9 @@ describe('organization integration management entry', () => { 'href', '/o/org-one/settings/integrations/providers/google_drive' ) - expect(container.textContent).toContain('Needs setup') - expect(container.textContent).toContain('2 sources') + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).not.toContain('Needs setup') + expect(container.textContent).toContain('Disabled') expect(container.textContent).toContain('Confluence') expect(container.textContent).not.toContain('Add integration') expect(document.querySelector('[aria-label="Allow Gmail in Sim Search"]')).toHaveAttribute( @@ -307,7 +334,7 @@ describe('organization integration management entry', () => { isPending: false, }) await render() - expect(container.textContent).toContain('Needs attention') + expect(container.textContent).toContain('Sync failed') expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index a918636313c..406f1bfd564 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -4,8 +4,13 @@ import { useState } from 'react' import { ChipConfirmModal, ChipLink, ChipModalError, Switch, toast } from '@sim/emcn' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' -import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { + canConnectWithDefaults, + getConnectorAccessAvailability, + SEARCH_SOURCE_TYPES, +} from '@/lib/sim-search/connectors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' @@ -97,12 +102,8 @@ export function OrganizationIntegrationsSetup() { ) const available = access.admin || access.members const hasSources = sourceCount > 0 - let description = hasSources - ? `${sourceCount} ${sourceCount === 1 ? 'source' : 'sources'}` - : approved - ? 'Needs setup' - : undefined - if (approved && provider?.status === 'needs_attention') description = 'Needs attention' + const manage = hasSources || canConnectWithDefaults(meta) + let description = provider ? organizationSearchStatusLabel(provider) : undefined if (!hasSources && availability.isIntegrationAvailabilityReady && !available) description = 'Unavailable in this deployment' return ( @@ -117,10 +118,10 @@ export function OrganizationIntegrationsSetup() { {(hasSources || (approved && available)) && ( - {hasSources ? 'Manage' : 'Set up'} + {manage ? 'Manage' : 'Set up'} )} { + it('describes the next step instead of calling all empty integrations unconfigured', () => { + expect(organizationSearchStatusLabel(provider)).toBe('Waiting for connections') + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_setup' })).toBe( + 'Source not configured' + ) + expect( + organizationSearchStatusLabel({ ...provider, status: 'needs_setup', sourceCount: 1 }) + ).toBe('Waiting for first sync') + expect(organizationSearchStatusLabel({ ...provider, status: 'active', sourceCount: 1 })).toBe( + 'Enabled' + ) + }) + it.each([ + ['sync_failed', 'Sync failed'], + ['account_sync_incomplete', 'Some accounts are not up to date'], + ['document_indexing_failed', 'Some documents failed to index'], + ] as const)('describes %s and keeps concurrent recovery visible', (issue, label) => { + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_attention', issue })).toBe( + label + ) + expect( + organizationSearchStatusLabel({ + ...provider, + status: 'needs_attention', + issue, + isSyncing: true, + }) + ).toBe(`Indexing · ${label}`) + }) + it('shows deactivation ahead of a retained failure', () => { + expect( + organizationSearchStatusLabel({ + ...provider, + approved: false, + status: 'needs_attention', + issue: 'sync_failed', + }) + ).toBe('Disabled') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 85a329f0467..547f0339812 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -1,15 +1,25 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/knowledge/connectors' const STATUS_LABELS: Record = { - needs_setup: 'Needs setup', - waiting_for_connections: 'Waiting for account connections', + needs_setup: 'Source not configured', + waiting_for_connections: 'Waiting for connections', indexing: 'Indexing', - needs_attention: 'Needs attention', + needs_attention: 'Sync failed', paused: 'Paused', - active: 'Syncing enabled', + active: 'Enabled', } export function organizationSearchStatusLabel(provider: OrganizationSearchProviderSummary): string { - if (!provider.approved) return 'Deactivated' + if (!provider.approved) return 'Disabled' + if (provider.status === 'needs_setup' && provider.sourceCount > 0) return 'Waiting for first sync' + if (provider.status === 'needs_attention') { + const error = + provider.issue === 'account_sync_incomplete' + ? 'Some accounts are not up to date' + : provider.issue === 'document_indexing_failed' + ? 'Some documents failed to index' + : 'Sync failed' + return provider.isSyncing ? `Indexing · ${error}` : error + } return STATUS_LABELS[provider.status] } diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx index 3873cc1d046..d4c214aeb1b 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx @@ -39,6 +39,8 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ })) vi.mock('@/lib/sim-search/connectors', () => ({ canConnectPersonally: () => mocks.personal, + canConnectWithDefaults: (meta: { name: string }) => + ['Gmail', 'Google Calendar', 'Google Drive'].includes(meta.name), getConnectorAccessAvailability: () => mocks.access, })) vi.mock('@/lib/oauth', () => ({ @@ -53,6 +55,10 @@ vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: { google_drive: { name: 'Google Drive', auth: { mode: 'oauth', provider: 'google-drive' } }, gmail: { name: 'Gmail', auth: { mode: 'oauth', provider: 'google-email' } }, + google_calendar: { + name: 'Google Calendar', + auth: { mode: 'oauth', provider: 'google-calendar' }, + }, slack: { name: 'Slack', auth: { mode: 'oauth', provider: 'slack' } }, gitlab: { name: 'GitLab', auth: { mode: 'apiKey' } }, }, @@ -202,6 +208,56 @@ describe('organization provider management', () => { }) } + it.each(['gmail', 'google_calendar', 'google_drive'])( + 'lets %s wait for connections without requiring source setup', + async (connectorType) => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType, + approved: true, + status: 'waiting_for_connections', + sourceCount: 0, + issue: null, + isSyncing: false, + }, + ], + }, + }) + mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) + mocks.sources.mockReturnValue({ data: [], isPending: false }) + await render(connectorType) + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).toContain( + 'Members connect their accounts from Integrations. Indexing starts automatically.' + ) + expect(container.textContent).not.toContain('Add source') + expect(container.textContent).not.toContain('Add sync configuration') + expect(container.querySelector('a[href="/o/org-one/integrations"]')).toHaveTextContent( + 'Open Integrations' + ) + expect(mocks.sources).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ) + await click('Advanced') + expect(container.textContent).toContain( + 'A sync configuration controls what gets indexed and how often.' + ) + expect(container.textContent).toContain('Add sync configuration') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenLastCalledWith( + expect.objectContaining({ searchParams: expect.any(URLSearchParams) }) + ) + expect(mocks.updateUrl.mock.calls.at(-1)![0].searchParams.get('addConnector')).toBe( + connectorType + ) + }) + } + ) + it.each(['active', 'disabled'])( 'removes only Slack account setup after confirmation, including a %s option', async (status) => { @@ -269,7 +325,7 @@ describe('organization provider management', () => { }) it('uses named source links even when the admin has not reconnected their own account', async () => { - await render() + await render('google_drive', '?view=sources') expect(mocks.sources).toHaveBeenCalledWith( { kind: 'organization', organizationId: 'org-one' }, { connectorType: 'google_drive', search: '', enabled: true } @@ -301,7 +357,7 @@ describe('organization provider management', () => { it('does not claim a provider is empty before paginated source discovery finishes', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ data: [], isPending: false, hasNextPage: true, fetchNextPage }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).not.toContain('No sources yet') await click('Load more') expect(fetchNextPage).toHaveBeenCalledOnce() @@ -317,14 +373,14 @@ describe('organization provider management', () => { hasNextPage: true, fetchNextPage, }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Engineering handbook') expect(container.textContent).toContain('More sources unavailable') await click('Try again') expect(fetchNextPage).toHaveBeenCalledOnce() }) - it.each(['', '?view=accounts'])( + it.each(['', '?view=accounts', '?view=sources'])( 'renders overview loading without presenting missing configuration at %s', async (params) => { mocks.overview.mockReturnValue({ isPending: true }) @@ -334,7 +390,7 @@ describe('organization provider management', () => { expect(mocks.people).not.toHaveBeenCalled() expect( container.querySelector( - `input[placeholder="${params ? 'Search people...' : 'Search sources...'}"]` + `input[placeholder="${params === '?view=sources' ? 'Search sync configurations...' : 'Search people...'}"]` ) ).toBeEnabled() } @@ -388,8 +444,14 @@ describe('organization provider management', () => { it.each([ ['loading', 'Loading accounts…'], ['error', 'Accounts unavailable'], - ['missing group', 'Add a source to set up account connections.'], - ['missing provider option', 'Add a source to set up account connections.'], + [ + 'missing group', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], + [ + 'missing provider option', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], ])('preserves Accounts search while %s', async (state, message) => { const refetch = vi.fn() mocks.accounts.mockReturnValue( @@ -410,7 +472,7 @@ describe('organization provider management', () => { expect(container.textContent).toContain(message) expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() - expect(container.querySelector('input[placeholder="Search sources..."]')).toBeNull() + expect(container.querySelector('input[placeholder="Search sync configurations..."]')).toBeNull() expect(mocks.people).not.toHaveBeenCalled() if (state === 'error') { await click('Try again') @@ -419,19 +481,19 @@ describe('organization provider management', () => { const sourcesTab = Array.from( container.querySelectorAll('[role="radio"]') - ).find((item) => item.textContent === 'Sources') + ).find((item) => item.textContent === 'Advanced') expect(sourcesTab).toBeDefined() await act(async () => sourcesTab!.click()) - expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( - 'handbook' - ) + expect( + container.querySelector('input[placeholder="Search sync configurations..."]') + ).toHaveValue('handbook') await click('Accounts') expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') }) it('preserves source navigation and retries connection availability failures', async () => { mocks.availabilityError = new Error('Connection availability could not be loaded') - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Connection availability could not be loaded') expect(container.querySelector('a[aria-label="Open Engineering handbook"]')).toHaveAttribute( 'href', @@ -491,10 +553,14 @@ describe('organization provider management', () => { data: { providers: [{ ...provider, connectorType: type }] }, }) await render(type) - await click('Add source') - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('addConnector')).toBe(type) - expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + await click('Advanced') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('addConnector')).toBe(type) + expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + }) } ) @@ -506,10 +572,12 @@ describe('organization provider management', () => { mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) await render('slack') await click('Set up Slack app') - await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled()) - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('connectedAccounts')).toBe('slack') - expect(query.has('addConnector')).toBe(false) + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('connectedAccounts')).toBe('slack') + expect(query.has('addConnector')).toBe(false) + }) }) it.each([ diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index 8080d989f0a..d1002eb31b2 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal, ChipModalError, ChipSwitch } from '@sim/emcn' +import { ChipConfirmModal, ChipLink, ChipModalError, ChipSwitch } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { format } from 'date-fns' import { useRouter } from 'next/navigation' @@ -11,7 +11,11 @@ import { SettingsPanel } from '@/components/settings/settings-panel' import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { organizationRoutes } from '@/lib/navigation/paths' import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' -import { canConnectPersonally, getConnectorAccessAvailability } from '@/lib/sim-search/connectors' +import { + canConnectPersonally, + canConnectWithDefaults, + getConnectorAccessAvailability, +} from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' @@ -52,9 +56,11 @@ interface OrganizationProviderDetailProps { export function OrganizationProviderDetail({ connectorType }: OrganizationProviderDetailProps) { const { organization, viewer, searchAccess } = useOrganizationContext() const router = useRouter() + const meta = CONNECTOR_META_REGISTRY[connectorType] + const automaticSetup = Boolean(meta && canConnectWithDefaults(meta) && searchAccess.memberScoped) const [view, setView] = useQueryState( organizationProviderTabParam.key, - organizationProviderTabParam.parser + organizationProviderTabParam.parser.withDefault(automaticSetup ? 'accounts' : 'sources') ) const [search, setSearch] = useSettingsSearch() const [peopleSearch, setPeopleSearch] = useOrganizationAccountPeopleSearch() @@ -62,7 +68,6 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const [deactivating, setDeactivating] = useState(false) const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false) const scope = { kind: 'organization', organizationId: organization.id } as const - const meta = CONNECTOR_META_REGISTRY[connectorType] const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped) const showAccounts = view === 'accounts' && personal const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) @@ -100,7 +105,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid if (!viewer.isAdmin || !meta) return null const searchField = showAccounts ? { value: peopleSearch, onChange: setPeopleSearch, placeholder: 'Search people...' } - : { value: search, onChange: setSearch, placeholder: 'Search sources...' } + : { + value: search, + onChange: setSearch, + placeholder: automaticSetup ? 'Search sync configurations...' : 'Search sources...', + } const panel = { back, title: meta.name, @@ -156,10 +165,14 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid approval.mutate({ organizationId: organization.id, connectorType, approved: true }) const actions: SettingsAction[] = approved ? [ - ...(access.admin || access.members + ...((access.admin || access.members) && (!automaticSetup || !showAccounts) ? [ { - text: needsSlackSetup ? 'Set up Slack app' : 'Add source', + text: needsSlackSetup + ? 'Set up Slack app' + : automaticSetup + ? 'Add sync configuration' + : 'Add source', icon: Plus, variant: 'primary' as const, disabled: @@ -208,6 +221,12 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const renderSources = () => ( + {automaticSetup && ( + + A sync configuration controls what gets indexed and how often. Connecting the first + account creates the default configuration automatically. + + )} {approval.error && ( {approval.error.message} @@ -253,12 +272,16 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid : !source.enabled ? 'Paused' : source.hasSyncError - ? 'Needs attention' - : source.isSyncing - ? 'Indexing' - : source.lastSyncAt - ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` - : 'Waiting for the first sync' + ? source.isSyncing + ? 'Indexing · Previous sync failed' + : 'Sync failed' + : source.viewerFailedDocumentCount > 0 + ? `${source.viewerFailedDocumentCount} ${source.viewerFailedDocumentCount === 1 ? 'document' : 'documents'} failed to index` + : source.isSyncing + ? 'Indexing' + : source.lastSyncAt + ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` + : 'Waiting for the first sync' } href={organizationRoutes(organization.id).searchSource(source.connectorId)} clickLabel={`Open ${source.sourceDescription || meta.name}`} @@ -271,7 +294,9 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid ? 'No matching sources' : !approved ? 'Activate this integration to set up sources.' - : 'No sources yet.'} + : automaticSetup + ? 'No accounts connected yet. A sync configuration will be created when someone connects.' + : 'No sources yet.'} )} @@ -288,10 +313,17 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid aria-label={`${meta.name} settings`} value={view} onChange={(value) => void setView(value)} - options={[ - { value: 'sources', label: 'Sources' }, - { value: 'accounts', label: 'Accounts' }, - ]} + options={ + automaticSetup + ? [ + { value: 'accounts', label: 'Accounts' }, + { value: 'sources', label: 'Advanced' }, + ] + : [ + { value: 'sources', label: 'Sources' }, + { value: 'accounts', label: 'Accounts' }, + ] + } /> )} @@ -336,9 +368,16 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {approved ? needsSlackSetup ? 'Set up the Slack app to connect accounts.' - : 'Add a source to set up account connections.' + : automaticSetup + ? 'Members connect their accounts from Integrations. Indexing starts automatically.' + : 'Add a source to set up account connections.' : 'Activate this integration to set up account connections.'} + {automaticSetup && approved && ( + + Open Integrations + + )} ) ) : ( 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 7670e68e7e6..648ca14f1f6 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 @@ -192,7 +192,7 @@ function SourceDetailContent({ : effectiveStatus === 'disabled' ? 'Sync disabled' : effectiveStatus === 'error' - ? 'Sync needs attention' + ? 'Sync failed' : undefined const description = [title === meta?.name ? undefined : meta?.name, status].filter(Boolean).join(' · ') || undefined diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx index 235965ee47f..bb4e0c18188 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx @@ -155,9 +155,9 @@ describe('Search source viewer actions', () => { it.each([ { change: { hasSyncError: true, viewerDocumentCount: 4 }, - status: 'Sync needs attention · 4 searchable documents', + status: 'Sync failed · 4 searchable documents', }, - { change: { hasSyncError: true }, status: 'Sync needs admin attention' }, + { change: { hasSyncError: true }, status: 'Sync failed' }, { change: { viewerFailedDocumentCount: 1 }, status: "1 document couldn't be indexed", diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx index edb387dc829..220ff3edd4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReactNode } from 'react' import { Chip, ChipLink } from '@sim/emcn' import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' @@ -19,9 +20,11 @@ interface SearchSourceRowProps { waiting: boolean isPending: boolean onConnect: () => void + connectLabel?: string manageHref?: string /** Opens management for the source; only a surface that offers management passes it. */ onManage?: () => void + accountActions?: ReactNode } /** Source health and the viewer's connection are separate; only the viewer's next action is primary. */ @@ -34,8 +37,10 @@ export function SearchSourceRow({ waiting, isPending, onConnect, + connectLabel = 'Connect account', manageHref, onManage, + accountActions, }: SearchSourceRowProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const meta = CONNECTOR_META_REGISTRY[source.connectorType] @@ -70,10 +75,7 @@ export function SearchSourceRow({ ? 'Your account needs to be reconnected' : 'Connect your account to search this source' else if (source.hasSyncError) - status = - source.viewerDocumentCount > 0 - ? `Sync needs attention · ${count}` - : 'Sync needs admin attention' + status = source.viewerDocumentCount > 0 ? `Sync failed · ${count}` : 'Sync failed' else if (source.viewerFailedDocumentCount > 0) status = `${source.viewerFailedDocumentCount} document${source.viewerFailedDocumentCount === 1 ? '' : 's'} couldn't be indexed${source.viewerDocumentCount > 0 ? ` · ${count}` : ''}` else if (source.isSyncing) @@ -108,7 +110,7 @@ export function SearchSourceRow({ ? 'Open again' : membership === 'needs_reauth' ? 'Reconnect' - : 'Connect account'} + : connectLabel} )} {canAdmin && @@ -122,6 +124,7 @@ export function SearchSourceRow({ ) : ( Manage ))} + {accountActions} ) } diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 2112668c818..7cefeba5d47 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -6,7 +6,6 @@ import { usePostHog } from 'posthog-js/react' import type { AccountSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' -import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( @@ -40,7 +39,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section }) }, [posthog, section]) - if (section === 'connected-accounts') return if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index c495c028a33..37ded7d6390 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -128,7 +128,6 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', - 'connected-accounts', 'admin', 'mothership', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 57ba6cb85e5..4d95c5461af 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -35,13 +35,7 @@ import { organizationRoutes } from '@/lib/navigation/paths' export type SettingsPlane = 'account' | 'selfhost' | 'workspace' -export type AccountSettingsSection = - | 'connected-accounts' - | 'general' - | 'billing' - | 'api-keys' - | 'admin' - | 'mothership' +export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -539,14 +533,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 1, organizationSection: 'connected-accounts', }, - planes: { - account: { - id: 'connected-accounts', - group: 'account', - order: 3, - description: 'Manage accounts you have contributed to organizations.', - }, - }, }, { label: 'Custom tools', diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx index 8b1cc2321da..99f2877eb61 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx @@ -2,48 +2,24 @@ 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 { afterEach, beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ people: vi.fn(), resend: vi.fn(), revoke: vi.fn(), - disconnect: vi.fn(), reset: vi.fn(), resendState: { isPending: false, error: null as Error | null }, revokeState: { isPending: false, error: null as Error | null }, })) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccountPeople: mocks.people, - usePersonalOrganizationAccounts: () => ({ - data: { - pages: [ - { - accounts: [ - { - credentialId: 'credential-1', - displayName: 'Personal Gmail', - organizationName: 'Example organization', - providerId: 'gmail', - status: 'active', - canReconnect: true, - }, - ], - }, - ], - }, - }), useResendOrganizationAccountInvitation: () => ({ ...mocks.resendState, mutate: mocks.resend }), useRevokeOrganizationAccountEnrollment: () => ({ ...mocks.revokeState, mutate: mocks.revoke, reset: mocks.reset, }), - useReconnectPersonalOrganizationAccount: () => ({}), - useDisconnectPersonalOrganizationAccount: () => ({ - mutate: mocks.disconnect, - reset: mocks.reset, - }), })) vi.mock('@/ee/credential-groups/components/organization-account-invite-modal', () => ({ OrganizationAccountInviteModal: () => null, @@ -51,7 +27,6 @@ vi.mock('@/ee/credential-groups/components/organization-account-invite-modal', ( import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' -import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' let root: Root let container: HTMLDivElement @@ -111,11 +86,6 @@ async function selectPersonAction(label: string) { return action } -async function openConfirmation(label: string) { - if (label === 'Revoke') await selectPersonAction(label) - else await act(async () => button(container, label).click()) -} - async function renderPeople(searchConnection?: { optionId: string; providerName: string }) { await act(async () => root.render( @@ -156,58 +126,28 @@ it('keeps the compact People rows and resends from the actions menu', async () = ) }) -const cases = [ - { - label: 'Revoke', - component: , - mutation: mocks.revoke, - target: 'person@example.com', - input: { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, - }, - { - label: 'Disconnect', - component: , - mutation: mocks.disconnect, - target: 'Personal Gmail', - input: 'credential-1', - }, -] as const - -describe.each(cases)( - '$label organization account access', - ({ label, component, mutation, target, input }) => { - it('requires confirmation, allows cancellation, and never submits from an unfocused Enter', async () => { - await act(async () => - root.render( - - - {component} - - - ) - ) - await openConfirmation(label) - let dialog = document.querySelector('[role="dialog"]') - expect(dialog?.textContent).toContain(target) - expect(mutation).not.toHaveBeenCalled() - await act(async () => - dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - expect(mutation).not.toHaveBeenCalled() - if (!dialog) throw new Error('Missing confirmation dialog') - await act(async () => button(dialog, 'Cancel').click()) - expect(mutation).not.toHaveBeenCalled() - await openConfirmation(label) - dialog = document.querySelector('[role="dialog"]') - if (!dialog) throw new Error('Missing confirmation dialog') - await act(async () => button(dialog, label).click()) - expect(mutation).toHaveBeenCalledExactlyOnceWith( - input, - expect.objectContaining({ onSuccess: expect.any(Function) }) - ) - }) - } -) +it('requires revoke confirmation, allows cancellation, and never submits from an unfocused Enter', async () => { + await renderPeople() + await selectPersonAction('Revoke') + let dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('person@example.com') + expect(mocks.revoke).not.toHaveBeenCalled() + await act(async () => + dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mocks.revoke).not.toHaveBeenCalled() + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, 'Cancel').click()) + expect(mocks.revoke).not.toHaveBeenCalled() + await selectPersonAction('Revoke') + dialog = document.querySelector('[role="dialog"]') + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, 'Revoke').click()) + expect(mocks.revoke).toHaveBeenCalledExactlyOnceWith( + { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) +}) it('restores the existing People URL search and requests server-filtered results', async () => { mocks.people.mockReturnValue({ data: { pages: [{ enrollments: [] }] }, hasNextPage: false }) diff --git a/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx b/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx deleted file mode 100644 index 1d19d8885de..00000000000 --- a/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalError, ChipTag, toast } from '@sim/emcn' -import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { - useDisconnectPersonalOrganizationAccount, - usePersonalOrganizationAccounts, - useReconnectPersonalOrganizationAccount, -} from '@/hooks/queries/organization-accounts' - -export function PersonalOrganizationAccounts() { - const accounts = usePersonalOrganizationAccounts() - const reconnect = useReconnectPersonalOrganizationAccount() - const disconnect = useDisconnectPersonalOrganizationAccount() - const [disconnectingId, setDisconnectingId] = useState(null) - const disconnectingAccount = accounts.data?.pages - .flatMap((page) => page.accounts) - .find((account) => account.credentialId === disconnectingId) - const pending = reconnect.isPending || disconnect.isPending - const error = reconnect.error ?? disconnect.error - return ( - -
-

- Manage accounts you connected to organizations. Disconnecting stops new indexing and - workflows that use the account, and removes Search access that depends on it. -

- {error && ( -

- {error.message} -

- )} - {accounts.error ? ( - void accounts.refetch()} - /> - ) : accounts.isPending ? ( -

Loading your accounts…

- ) : ( - accounts.data?.pages - .flatMap((page) => page.accounts) - .map((account) => ( - - {account.enrollmentStatus === 'revoked' ? 'Access revoked' : account.status} - - } - trailing={ -
- - reconnect.mutate(account.credentialId, { - onSuccess: ({ invitationLink }) => { - window.location.assign(invitationLink) - }, - }) - } - > - Reconnect - - { - disconnect.reset() - setDisconnectingId(account.credentialId) - }} - > - Disconnect - -
- } - /> - )) - )} - {accounts.data?.pages[0]?.accounts.length === 0 && ( -

- You haven’t contributed accounts to an organization yet. Use your invitation link to get - started. -

- )} - {accounts.hasNextPage && ( -
- void accounts.fetchNextPage()} - > - Load more - -
- )} - {disconnectingAccount && ( - { - if (!open && !disconnect.isPending) setDisconnectingId(null) - }} - title={`Disconnect ${disconnectingAccount.displayName}`} - text={`${disconnectingAccount.organizationName} will stop indexing and running workflows with this account. You will lose Search access that depends on this connection.`} - defaultAction='none' - confirm={{ - label: 'Disconnect', - pendingLabel: 'Disconnecting…', - pending: disconnect.isPending, - variant: 'destructive', - onClick: () => - disconnect.mutate(disconnectingAccount.credentialId, { - onSuccess: () => { - setDisconnectingId(null) - toast.success('Account disconnected') - }, - }), - }} - > - {disconnect.error?.message} - - )} -
-
- ) -} diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 3a79d0be8e6..675d6e3e4f1 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -44,6 +44,7 @@ import { readSearchIndexContract, readSearchSourceOverviewContract, readSearchSourceProgressContract, + type SearchConnectionOAuthQuery, type SearchSourcePage, type SearchSourceProgress, } from '@/lib/api/contracts/knowledge/connectors' @@ -418,7 +419,7 @@ async function updateConnectorAccess({ return result.data } -interface StartConnectorMemberEnrollmentParams { +interface StartConnectorMemberEnrollmentParams extends SearchConnectionOAuthQuery { knowledgeBaseId: string connectorId: string } @@ -426,9 +427,11 @@ interface StartConnectorMemberEnrollmentParams { async function startConnectorMemberEnrollment({ knowledgeBaseId, connectorId, + oauthCompletionId, }: StartConnectorMemberEnrollmentParams): Promise { const response = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, { params: { id: knowledgeBaseId, connectorId }, + query: { oauthCompletionId }, }) return response.data } diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index d26b2bb931d..1a45f1fd069 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -10,17 +10,86 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) import { ApiClientError } from '@/lib/api/client/errors' import { + disconnectPersonalOrganizationAccountContract, listOrganizationAccountPeopleContract, updateOrganizationAccountsContract, } from '@/lib/api/contracts/organization-accounts' import { organizationAccountsKeys, + useDisconnectPersonalOrganizationAccount, useOrganizationAccountPeople, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' import { slackSearchKeys } from '@/hooks/queries/slack-search' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +describe('personal account disconnect', () => { + it.each([true, false])( + 'refreshes this organization only after success=%s, including after unmount', + async (success) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + const response = Promise.withResolvers<{ success: true }>() + mocks.request.mockReturnValue(response.promise) + const client = new QueryClient() + const root = createRoot(document.createElement('div')) + let mutation: ReturnType + function Probe() { + mutation = useDisconnectPersonalOrganizationAccount('org-1') + return null + } + const own = searchSourceKeys.pages( + { kind: 'organization', organizationId: 'org-1' }, + { mine: true, search: '' } + ) + const catalog = searchSourceKeys.pages( + { kind: 'organization', organizationId: 'org-1' }, + { mine: false, search: '' } + ) + const people = organizationAccountsKeys.people('org-1') + const other = searchSourceKeys.list({ kind: 'organization', organizationId: 'org-2' }) + for (const key of [own, catalog, people, other]) client.setQueryData(key, { existing: true }) + try { + await act(async () => + root.render( + + + + ) + ) + let pending: Promise + await act(async () => { + pending = mutation.mutateAsync('own-credential') + }) + await act(async () => + root.render({null}) + ) + await act(async () => { + if (success) { + response.resolve({ success: true }) + await pending + } else { + const rejection = expect(pending).rejects.toThrow('Try again') + response.reject(new Error('Try again')) + await rejection + } + }) + expect(mocks.request).toHaveBeenCalledExactlyOnceWith( + disconnectPersonalOrganizationAccountContract, + { params: { credentialId: 'own-credential' } } + ) + for (const key of [own, catalog, people]) + expect(client.getQueryState(key)?.isInvalidated).toBe(success) + expect(client.getQueryState(other)?.isInvalidated).toBe(false) + } finally { + await act(async () => root.unmount()) + client.clear() + vi.unstubAllGlobals() + } + } + ) +}) + describe('organization account setup updates', () => { it.each([true, false])( 'refreshes only this organization’s setup after the caller unmounts on success=%s', diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index fce40bb80ae..d6c08965dd8 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -24,11 +24,9 @@ import { type InviteOrganizationAccountPeopleBody, inviteOrganizationAccountPeopleContract, listOrganizationAccountPeopleContract, - listPersonalOrganizationAccountsContract, type OrganizationAccountPeopleQuery, type RemoveOrganizationAccountMcpProviderParams, type ResendOrganizationAccountInvitationQuery, - reconnectPersonalOrganizationAccountContract, removeOrganizationAccountMcpProviderContract, resendOrganizationAccountInvitationContract, revokeOrganizationAccountEnrollmentContract, @@ -43,9 +41,28 @@ import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 +/** Disconnects an owned grant; indexing and source setup do not gate this operation. */ +export function useDisconnectPersonalOrganizationAccount(organizationId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(disconnectPersonalOrganizationAccountContract, { + params: { credentialId }, + }), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: searchSourceKeys.list({ kind: 'organization', organizationId }), + }), + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + ]), + }) +} + export const organizationAccountsKeys = { all: ['organization-accounts'] as const, - personal: () => [...organizationAccountsKeys.all, 'personal'] as const, workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, workspace: (workspaceId?: string) => [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, @@ -117,7 +134,6 @@ export function useConfigureOrganizationMcp() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), ]), }) } @@ -144,7 +160,6 @@ export function useUpdateOrganizationAccounts() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), @@ -294,12 +309,7 @@ export function useRevokeOrganizationAccountEnrollment() { params: { id: organizationId, enrollmentId }, }), onSuccess: (_, { organizationId }) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: organizationAccountsKeys.people(organizationId), - }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - ]), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.people(organizationId) }), }) } export function useAddOrganizationAccountMcpProvider() { @@ -341,39 +351,6 @@ export function useRemoveOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - ]), - }) -} - -export function usePersonalOrganizationAccounts() { - return useInfiniteQuery({ - queryKey: organizationAccountsKeys.personal(), - staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, - initialPageParam: undefined as string | undefined, - queryFn: ({ signal, pageParam }) => - requestJson(listPersonalOrganizationAccountsContract, { - query: { cursor: pageParam }, - signal, - }), - getNextPageParam: (page) => page.nextCursor ?? undefined, - }) -} -export function useReconnectPersonalOrganizationAccount() { - return useMutation({ - mutationFn: (credentialId: string) => - requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }), - }) -} -export function useDisconnectPersonalOrganizationAccount() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (credentialId: string) => - requestJson(disconnectPersonalOrganizationAccountContract, { params: { credentialId } }), - onSuccess: () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.details() }), ]), }) } diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index f82c4e036a0..9fe93f8ef8f 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -9,6 +9,11 @@ const mocks = vi.hoisted(() => ({ enrollmentMutate: vi.fn(), sourceConnectionMutate: vi.fn(), invalidateQueries: vi.fn(), + channels: [] as Array<{ + name: string + onmessage: ((event: MessageEvent) => void) | null + close: ReturnType + }>, })) vi.mock('@tanstack/react-query', () => ({ @@ -40,17 +45,27 @@ let root: Root | null = null let container: HTMLDivElement | null = null let enrollmentTab: { location: { href: string }; closed: boolean; close: () => void } -function Harness({ connected }: { connected: ReadonlySet }) { - latest = useMemberEnrollment({ membershipQueryKeys: [], connectedConnectorIds: connected }) +function Harness({ + connected, + directOAuth, +}: { + connected: ReadonlySet + directOAuth?: boolean +}) { + latest = useMemberEnrollment({ + membershipQueryKeys: [], + connectedConnectorIds: connected, + directOAuth, + }) return null } -function mount(connected: ReadonlySet = new Set()) { +function mount(connected: ReadonlySet = new Set(), directOAuth = false) { ;(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()) + act(() => root?.render()) } function enrollment(): Enrollment { @@ -61,6 +76,17 @@ function enrollment(): Enrollment { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() + mocks.channels.length = 0 + vi.stubGlobal( + 'BroadcastChannel', + class { + onmessage: ((event: MessageEvent) => void) | null = null + close = vi.fn() + constructor(public name: string) { + mocks.channels.push(this) + } + } + ) enrollmentTab = { location: { href: '' }, closed: false, @@ -77,9 +103,92 @@ afterEach(() => { latest = null vi.useRealTimers() vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('useMemberEnrollment', () => { + it('opens provider OAuth and waits for its own completion even if the account was already connected', () => { + mount(new Set(['connector-1']), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + const [input, handlers] = mocks.enrollmentMutate.mock.calls[0] + expect(input.oauthCompletionId).toMatch(/^[a-f\d-]{36}$/) + expect(mocks.channels[0].name).toBe(`sim:credential-group-oauth:${input.oauthCompletionId}`) + act(() => handlers.onSuccess({ url: 'https://provider.test/authorize' })) + expect(enrollmentTab.location.href).toBe('https://provider.test/authorize') + act(() => vi.advanceTimersByTime(4_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(true) + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toBeNull() + expect(mocks.invalidateQueries).toHaveBeenCalled() + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('keeps overlapping provider authorizations separate and reports a rejected one on the original page', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/one' }) + ) + act(() => enrollment().connect('kb-1', 'connector-2')) + act(() => + mocks.enrollmentMutate.mock.calls[1][1].onSuccess({ url: 'https://provider.test/two' }) + ) + expect(mocks.channels[0].name).not.toBe(mocks.channels[1].name) + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'denied' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().isAwaiting('connector-2')).toBe(true) + expect(enrollment().error).toContain('Authorization was canceled') + act(() => mocks.channels[1].onmessage?.(new MessageEvent('message', { data: 'unrecognized' }))) + expect(enrollment().isAwaiting('connector-2')).toBe(true) + }) + + it('keeps listening for OAuth completion when provider isolation reports a closed window', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/authorize' }) + ) + enrollmentTab.closed = true + act(() => vi.advanceTimersByTime(4_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(true) + expect(enrollment().error).toBeNull() + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toBeNull() + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('stops waiting with an actionable error when direct OAuth expires', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ + url: 'https://provider.test/authorize', + }) + ) + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toContain('expired') + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('passes direct authorization correlation through first-source setup and cleans it up on failure', () => { + mount(new Set(), true) + act(() => + enrollment().connectSource({ kind: 'organization', organizationId: 'org-1' }, 'gmail') + ) + const [input, handlers] = mocks.sourceConnectionMutate.mock.calls[0] + expect(input).toMatchObject({ + organizationId: 'org-1', + connectorType: 'gmail', + oauthCompletionId: expect.any(String), + }) + act(() => handlers.onError(new Error('Unavailable'))) + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + expect(enrollmentTab.close).toHaveBeenCalledOnce() + }) + it.each(['blocked', 'failed', 'closed', 'success'] as const)( 'retains source setup until enrollment navigation succeeds: %s', (outcome) => { diff --git a/apps/sim/hooks/use-member-enrollment.ts b/apps/sim/hooks/use-member-enrollment.ts index e9e41586254..4d1b2b14ac1 100644 --- a/apps/sim/hooks/use-member-enrollment.ts +++ b/apps/sim/hooks/use-member-enrollment.ts @@ -1,9 +1,15 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { type QueryKey, useQueryClient } from '@tanstack/react-query' import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + credentialGroupOAuthCompletionChannel, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' import type { MemberSyncStatus } from '@/lib/knowledge/types' import type { SearchConnector } from '@/lib/sim-search/connectors' import { @@ -93,6 +99,7 @@ interface AwaitingEnrollment { * can be told it is awaited before its membership row exists to look it up by. */ connectorType: string | null + oauthCompletionId?: string } interface UseMemberEnrollmentProps { @@ -100,6 +107,8 @@ interface UseMemberEnrollmentProps { membershipQueryKeys: readonly QueryKey[] /** Connector ids the viewer is now connected to; awaiting stops for them. */ connectedConnectorIds: ReadonlySet + /** Main Integrations skips the invitation page; invitation-based surfaces keep their flow. */ + directOAuth?: boolean } /** @@ -116,8 +125,12 @@ interface UseMemberEnrollmentProps { export function useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds, + directOAuth = false, }: UseMemberEnrollmentProps) { const connectedRef = useRef(connectedConnectorIds) + const oauthPopups = useRef( + new Map }>() + ) const queryClient = useQueryClient() const enrollment = useStartConnectorMemberEnrollment() const sourceConnection = useConnectSimSearchConnector() @@ -125,6 +138,39 @@ export function useMemberEnrollment({ () => new Map() ) const [popupBlocked, setPopupBlocked] = useState(false) + const [oauthError, setOAuthError] = useState(null) + + const refreshMemberships = useCallback(() => { + for (const queryKey of membershipQueryKeys) { + void queryClient.invalidateQueries({ queryKey }) + } + void queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) + }, [membershipQueryKeys, queryClient]) + + const finishOAuth = (completionId: string, error: string | null) => { + const popup = oauthPopups.current.get(completionId) + if (!popup) return + clearTimeout(popup.timer) + popup.channel.close() + oauthPopups.current.delete(completionId) + setAwaitingSince( + (current) => + new Map([...current].filter(([, entry]) => entry.oauthCompletionId !== completionId)) + ) + setOAuthError(error) + refreshMemberships() + } + + useEffect(() => { + const popups = oauthPopups.current + return () => { + for (const popup of popups.values()) { + clearTimeout(popup.timer) + popup.channel.close() + } + popups.clear() + } + }, []) useEffect(() => { connectedRef.current = connectedConnectorIds @@ -134,6 +180,8 @@ export function useMemberEnrollment({ * Polls while any connection is awaited, and once more after the last one * connects: that tick drops the connected ids, so a token that later needs * reauthorization is not mistaken for a connection still being awaited. + * Direct OAuth waits for its completion message: provider window isolation + * can report a closed handle while authorization is still in progress. */ const awaiting = awaitingSince.size > 0 useEffect(() => { @@ -143,25 +191,23 @@ export function useMemberEnrollment({ setAwaitingSince((current) => { const next = new Map( [...current].filter( - ([id, { since, tab }]) => - !tab.closed && - !connectedRef.current.has(id) && + ([id, { since, tab, oauthCompletionId }]) => + (Boolean(oauthCompletionId) || !tab.closed) && + (Boolean(oauthCompletionId) || !connectedRef.current.has(id)) && now - since < AWAITING_CONNECTION_TIMEOUT_MS ) ) return next.size === current.size ? current : next }) - for (const queryKey of membershipQueryKeys) { - void queryClient.invalidateQueries({ queryKey }) - } - void queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) + refreshMemberships() }, AWAITING_CONNECTION_POLL_MS) return () => clearInterval(timer) - }, [awaiting, membershipQueryKeys, queryClient]) + }, [awaiting, refreshMemberships]) /** Opens the tab inside the click, then sends it wherever `start` mints. */ const openEnrollment = ( start: (handlers: { + oauthCompletionId?: string onSuccess: (url: string, connectorId: string, connectorType?: string) => boolean onError: () => void }) => void @@ -173,27 +219,50 @@ export function useMemberEnrollment({ } tab.opener = null setPopupBlocked(false) + setOAuthError(null) + const oauthCompletionId = directOAuth ? generateId() : undefined + if (oauthCompletionId) { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(oauthCompletionId)) + channel.onmessage = ({ data }: MessageEvent) => { + if (data === 'connected') finishOAuth(oauthCompletionId, null) + else if (isCredentialGroupOAuthFailure(data)) + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data]) + } + const timer = setTimeout(() => { + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.expired) + }, AWAITING_CONNECTION_TIMEOUT_MS) + oauthPopups.current.set(oauthCompletionId, { channel, timer }) + } start({ + ...(oauthCompletionId ? { oauthCompletionId } : {}), onSuccess: (url, connectorId, connectorType) => { - if (tab.closed) return false + if (tab.closed || (oauthCompletionId && !oauthPopups.current.has(oauthCompletionId))) { + if (oauthCompletionId) + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.denied) + return false + } tab.location.href = url setAwaitingSince((current) => new Map(current).set(connectorId, { since: Date.now(), tab, connectorType: connectorType ?? null, + ...(oauthCompletionId ? { oauthCompletionId } : {}), }) ) return true }, - onError: () => tab.close(), + onError: () => { + if (oauthCompletionId) finishOAuth(oauthCompletionId, null) + tab.close() + }, }) } const connect = (knowledgeBaseId: string, connectorId: string) => - openEnrollment(({ onSuccess, onError }) => { + openEnrollment(({ onSuccess, onError, oauthCompletionId }) => { enrollment.mutate( - { knowledgeBaseId, connectorId }, + { knowledgeBaseId, connectorId, ...(oauthCompletionId ? { oauthCompletionId } : {}) }, { onSuccess: ({ url }) => onSuccess(url, connectorId), onError: (err) => { @@ -214,12 +283,13 @@ export function useMemberEnrollment({ connectorType: string, sourceConfig?: Record ) => - openEnrollment(({ onSuccess, onError }) => { + openEnrollment(({ onSuccess, onError, oauthCompletionId }) => { sourceConnection.mutate( { ...(typeof owner === 'string' ? { workspaceId: owner } : resourceScopeFields(owner)), connectorType, sourceConfig, + ...(oauthCompletionId ? { oauthCompletionId } : {}), }, { onSuccess: ({ url, connectorId }) => { @@ -257,7 +327,9 @@ export function useMemberEnrollment({ } const isAwaiting = (connectorId: string) => - awaitingSince.has(connectorId) && !connectedConnectorIds.has(connectorId) + awaitingSince.has(connectorId) && + (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) || + !connectedConnectorIds.has(connectorId)) /** * Whether a Sim Search source is awaited by the connect that created its @@ -266,7 +338,9 @@ export function useMemberEnrollment({ */ const isAwaitingSource = (connectorType: string) => [...awaitingSince].some( - ([id, awaiting]) => awaiting.connectorType === connectorType && !connectedConnectorIds.has(id) + ([id, awaiting]) => + awaiting.connectorType === connectorType && + (Boolean(awaiting.oauthCompletionId) || !connectedConnectorIds.has(id)) ) /** The surface reports the latest attempt, whichever path made it. */ @@ -281,6 +355,6 @@ export function useMemberEnrollment({ isAwaiting, isAwaitingSource, isPending: enrollment.isPending || sourceConnection.isPending, - error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (latest.error?.message ?? null), + error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (oauthError ?? latest.error?.message ?? null), } } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ded0a02984b..438765f3c7b 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -19,6 +19,7 @@ import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH, MAX_SEARCH_SOURCE_PROGRESS_ITEMS, MAX_SEARCH_SOURCE_PROVIDER_TYPES, + SEARCH_SOURCE_CANDIDATE_PAGE_SIZE, SEARCH_SOURCE_PAGE_SIZE, } from '@/lib/knowledge/constants' import { MEMBER_SYNC_STATUSES } from '@/lib/knowledge/types' @@ -295,17 +296,23 @@ export const updateKnowledgeConnectorAccessContract = defineRouteContract({ }) export const startKnowledgeConnectorMemberEnrollmentDataSchema = z.object({ - /** The viewer's enrollment link; opening it connects their account. */ + /** The viewer's invitation link or direct provider authorization URL. */ url: z.string().url(), }) export type StartKnowledgeConnectorMemberEnrollmentData = z.output< typeof startKnowledgeConnectorMemberEnrollmentDataSchema > +export const searchConnectionOAuthQuerySchema = z.object({ + oauthCompletionId: z.string().uuid().optional(), +}) +export type SearchConnectionOAuthQuery = z.input + export const startKnowledgeConnectorMemberEnrollmentContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/connectors/[connectorId]/enroll', params: knowledgeConnectorParamsSchema, + query: searchConnectionOAuthQuerySchema, response: { mode: 'json', schema: successResponseSchema(startKnowledgeConnectorMemberEnrollmentDataSchema), @@ -342,6 +349,14 @@ const searchSourceSummaryFields = { viewerDocumentCount: z.number().int().nonnegative(), viewerFailedDocumentCount: z.number().int().nonnegative().default(0), viewerEmailVerified: z.boolean(), + viewerAccounts: z + .array( + z.object({ + credentialId: z.string().min(1).max(128), + displayName: z.string(), + }) + ) + .max(SEARCH_SOURCE_CANDIDATE_PAGE_SIZE), } export const searchSourceSummarySchema = z.discriminatedUnion('connectionRequired', [ @@ -357,6 +372,7 @@ export const searchSourceSummarySchema = z.discriminatedUnion('connectionRequire }), ]) export type SearchSourceSummary = z.output +export type ViewerSearchSourceAccount = SearchSourceSummary['viewerAccounts'][number] export const searchSourceCursorSchema = z.object({ createdAt: z.string().datetime(), @@ -422,6 +438,7 @@ export const organizationSearchProviderSummarySchema = z.object({ approved: z.boolean(), sourceCount: z.number().int().nonnegative(), status: organizationSearchProviderStatusSchema, + issue: z.enum(['sync_failed', 'account_sync_incomplete', 'document_indexing_failed']).nullable(), isSyncing: z.boolean(), }) export type OrganizationSearchProviderSummary = z.output< @@ -477,6 +494,7 @@ export const connectSimSearchConnectorBodySchema = resourceOwnerSchema.safeExten connectorId: knowledgeConnectorParamsSchema.shape.connectorId.max(255).optional(), /** Settings identify a compatible source, or assert the configuration of a selected source. */ sourceConfig: z.record(z.string(), z.string().max(500)).optional(), + oauthCompletionId: searchConnectionOAuthQuerySchema.shape.oauthCompletionId, }) export type ConnectSimSearchConnectorBody = z.input diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts new file mode 100644 index 00000000000..31225358b61 --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -0,0 +1,26 @@ +import { isValidUuid } from '@sim/utils/id' + +export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { + expired: 'This connection attempt expired. Try connecting your account again.', + denied: 'Authorization was canceled. Try connecting your account again.', + account_mismatch: 'Choose the account matching your Sim email address.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'The connection settings changed. Try connecting your account again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + unavailable: 'This connection is unavailable. Try connecting your account again.', + failed: 'Account authorization did not complete. Try connecting your account again.', +} as const + +export type CredentialGroupOAuthFailure = keyof typeof CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES + +/** Correlation only: completion notifications trigger authoritative query refreshes, never grants. */ +export function credentialGroupOAuthCompletionChannel(completionId: string): string { + if (!isValidUuid(completionId)) throw new Error('Invalid OAuth completion ID') + return `sim:credential-group-oauth:${completionId}` +} + +export function isCredentialGroupOAuthFailure( + value: unknown +): value is CredentialGroupOAuthFailure { + return typeof value === 'string' && Object.hasOwn(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, value) +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts index 31b7ab7ae03..799f7157e65 100644 --- a/apps/sim/lib/credential-groups/oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -71,6 +71,7 @@ describe('credential group OAuth state', () => { codeVerifier: 'code-verifier', invitationToken: 'invitation-token', completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', }) const stored = [...values.values()][0] @@ -91,6 +92,7 @@ describe('credential group OAuth state', () => { codeVerifier: 'code-verifier', invitationToken: 'invitation-token', completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', }) expect(credentialGroupOAuthNonceMatches(created.nonce, consumed?.nonceHash ?? '')).toBe(true) await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts index 53bd32c0c47..38f726ecfc6 100644 --- a/apps/sim/lib/credential-groups/oauth-state.ts +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -1,6 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' -import { generateId } from '@sim/utils/id' +import { generateId, isValidUuid } from '@sim/utils/id' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' @@ -38,6 +38,7 @@ interface StoredCredentialGroupOAuthAttempt { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' nonceHash: string encryptedCodeVerifier?: string @@ -61,6 +62,7 @@ export interface CredentialGroupOAuthAttempt { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' codeVerifier?: string invitationToken: string @@ -81,6 +83,7 @@ interface CreateCredentialGroupOAuthAttemptParams { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' codeVerifier?: string invitationToken: string @@ -129,6 +132,10 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt typeof candidate.redirectUri === 'string' && (candidate.completionRedirect === undefined || typeof candidate.completionRedirect === 'boolean') && + (candidate.completionId === undefined || + (candidate.completionRedirect === true && + typeof candidate.completionId === 'string' && + isValidUuid(candidate.completionId))) && (candidate.returnTo === undefined || candidate.returnTo === 'search' || candidate.returnTo === 'accounts') && @@ -144,6 +151,12 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt export async function createCredentialGroupOAuthAttempt( params: CreateCredentialGroupOAuthAttemptParams ): Promise<{ state: string; nonce: string }> { + if ( + params.completionId !== undefined && + (!params.completionRedirect || !isValidUuid(params.completionId)) + ) { + throw new Error('OAuth completion requires a valid correlation ID and completion redirect') + } const redis = requireRedis() const state = `${OAUTH_ATTEMPT_STATE_PREFIX}${generateId()}` const nonce = generateId() @@ -165,6 +178,7 @@ export async function createCredentialGroupOAuthAttempt( requiredScopes: params.requiredScopes, redirectUri: params.redirectUri, ...(params.completionRedirect ? { completionRedirect: true } : {}), + ...(params.completionId ? { completionId: params.completionId } : {}), ...(params.returnTo ? { returnTo: params.returnTo } : {}), nonceHash: sha256Hex(nonce), ...(encryptedCodeVerifier ? { encryptedCodeVerifier: encryptedCodeVerifier.encrypted } : {}), @@ -220,6 +234,7 @@ export async function consumeCredentialGroupOAuthAttempt( requiredScopes: parsed.requiredScopes, redirectUri: parsed.redirectUri, ...(parsed.completionRedirect ? { completionRedirect: true } : {}), + ...(parsed.completionId ? { completionId: parsed.completionId } : {}), ...(parsed.returnTo ? { returnTo: parsed.returnTo } : {}), ...(codeVerifier ? { codeVerifier: codeVerifier.decrypted } : {}), invitationToken: invitationToken.decrypted, diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index e748e546aa2..b9879eba19f 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -116,7 +116,11 @@ describe('credential group OAuth persistence', () => { }) createAttempt.mockResolvedValue({ state: 'state', nonce: 'nonce' }) await expect( - startCredentialGroupOAuth(CONTEXT, 'invitation', { returnTo: 'search' }) + startCredentialGroupOAuth(CONTEXT, 'invitation', { + returnTo: 'search', + completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', + }) ).resolves.toBe('https://provider.test/authorize') expect(createAttempt).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ @@ -128,6 +132,8 @@ describe('credential group OAuth persistence', () => { scopeVersion: POLICY.scopeVersion, requiredScopes: POLICY.requiredScopes, returnTo: 'search', + completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', invitationToken: 'invitation', }) ) diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 5bf8431d5d4..a6c2a0858b1 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -110,7 +110,11 @@ const logger = createLogger('CredentialGroupOAuth') export async function startCredentialGroupOAuth( context: CredentialGroupOAuthContext, invitationToken: string, - options: { completionRedirect?: boolean; returnTo?: 'search' | 'accounts' } = {} + options: { + completionRedirect?: boolean + completionId?: string + returnTo?: 'search' | 'accounts' + } = {} ): Promise { if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() const adapter = getOptionAdapter(context) @@ -130,6 +134,7 @@ export async function startCredentialGroupOAuth( redirectUri: prepared.redirectUri, codeVerifier: prepared.codeVerifier, completionRedirect: options.completionRedirect, + completionId: options.completionId, returnTo: options.returnTo, invitationToken, }) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index a0f47466ceb..5f046e65909 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -10,6 +10,7 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies' import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' @@ -111,6 +112,7 @@ export const internalKnowledgeErrorPolicies = { internalKnowledgeErrorPolicy('Failed to process knowledge tag request') ), connectors: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + connectAccount: concealKnowledgeBase(internalPersonalCredentialConnectionErrorPolicy), uploads: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy), } as const diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index b0189ecae5e..0e0358850c4 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ provision: vi.fn(), memberAccess: vi.fn(), sourceAccess: vi.fn(), + oauthContext: vi.fn(), + startOAuth: vi.fn(), })) vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() })) @@ -65,8 +67,13 @@ vi.mock('@/lib/knowledge/connectors/member-access', () => ({ vi.mock('@/lib/credential-groups/self-enrollment', () => ({ createViewerCredentialGroupEnrollment: async (...args: unknown[]) => ({ invitationLink: await mocks.enrollment(...args), + enrollment: { id: 'enrollment', email: 'person@example.test' }, }), })) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContextForEnrollment: mocks.oauthContext, +})) +vi.mock('@/lib/credential-groups/oauth', () => ({ startCredentialGroupOAuth: mocks.startOAuth })) vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ sourceIdentityBinding: mocks.identityBinding, @@ -122,9 +129,69 @@ beforeEach(() => { mocks.identityBinding.mockReturnValue(null) mocks.memberAccess.mockResolvedValue(undefined) mocks.sourceAccess.mockResolvedValue(undefined) + mocks.oauthContext.mockResolvedValue({ credentialOwnerId: 'admin', option: { id: 'option' } }) + mocks.startOAuth.mockResolvedValue('https://provider.example.test/authorize') }) describe('source member enrollment', () => { + it.each(['admin', 'members'])( + 'starts provider OAuth directly for a Search %s source', + async (accessMode) => { + const completionId = '550e8400-e29b-41d4-a716-446655440000' + mocks.context.mockResolvedValue({ + workspaceId: 'workspace', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + knowledgeBaseId: 'kb', + connectorId: 'source', + knowledgeBase: { workspaceId: 'workspace', id: 'kb', name: 'Search', isSearchIndex: true }, + }) + mocks.connector.mockResolvedValue({ + ...row, + accessMode, + credentialGroupId: 'group', + credentialGroupOptionId: 'option', + }) + mocks.meta.mockReturnValue({ name: 'Confluence', search: true, requiresMemberIdentity: true }) + mocks.identityBinding.mockReturnValue({ + credentialGroupId: 'group', + credentialGroupOptionId: 'option', + }) + await expect( + startKnowledgeConnectorMemberEnrollment.execute({ + principal, + input: { ...input, oauthCompletionId: completionId }, + }) + ).resolves.toEqual({ url: 'https://provider.example.test/authorize' }) + expect(mocks.oauthContext).toHaveBeenCalledWith( + { + workspaceId: 'workspace', + credentialGroupId: 'group', + enrollmentId: 'enrollment', + email: 'person@example.test', + userId: 'admin', + }, + 'option' + ) + expect(mocks.startOAuth).toHaveBeenCalledWith( + { credentialOwnerId: 'admin', option: { id: 'option' } }, + 'enroll', + { completionRedirect: true, returnTo: 'search', completionId } + ) + } + ) + + it('rejects direct OAuth for a non-Search source before creating an enrollment', async () => { + await expect( + startKnowledgeConnectorMemberEnrollment.execute({ + principal, + input: { ...input, oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000' }, + }) + ).rejects.toThrow('requires a Search source') + expect(mocks.enrollment).not.toHaveBeenCalled() + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + it.each(['admin', 'members'])( 'focuses a Search %s source on its exact validated account option', async (accessMode) => { diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index ed5daa0312e..aded446945e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -10,6 +10,8 @@ import { } from '@/lib/core/resource-scope' import { generateRequestId } from '@/lib/core/utils/request' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' +import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' import { requireKnowledgeMemberAccessAvailable, @@ -55,6 +57,8 @@ export interface StartKnowledgeConnectorMemberEnrollmentInput { connectorId: string assertedWorkspaceId?: string assertedOrganizationId?: string + /** Opens provider OAuth directly and correlates its completion with the initiating tab. */ + oauthCompletionId?: string } /** @@ -71,7 +75,7 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge principal: Principal input: StartKnowledgeConnectorMemberEnrollmentInput }) => resolveActiveKnowledgeConnectorContext(input, principal), - async execute({ principal, context }) { + async execute({ principal, context, input }) { const owner = resourceScopeFields(resourceScopeFromOwner(context.knowledgeBase)) const userId = resolvePrincipalSubjectUserId(principal) if (!userId) throw new OrchestrationError('forbidden', 'Sign in to connect your account') @@ -85,7 +89,42 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge if (!connectorMeta || (context.knowledgeBase.isSearchIndex && !connectorMeta.search)) { throw new OrchestrationError('validation', 'This connector is unavailable for Search') } - const enrollmentUrl = (invitationLink: string, optionId: string) => { + if (input.oauthCompletionId && !context.knowledgeBase.isSearchIndex) { + throw new OrchestrationError( + 'validation', + 'Direct account connection requires a Search source' + ) + } + const enrollmentUrl = async (credentialGroupId: string, optionId: string) => { + const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment({ + userId, + ...owner, + credentialGroupId, + }) + if (input.oauthCompletionId) { + const token = new URL(invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Account enrollment did not return an invitation token') + const oauth = await getCredentialGroupOAuthContextForEnrollment( + { + ...owner, + credentialGroupId, + enrollmentId: enrollment.id, + email: enrollment.email, + userId, + }, + optionId + ) + if (!oauth) + throw new OrchestrationError( + 'forbidden', + 'This account connection is no longer available' + ) + return startCredentialGroupOAuth(oauth, token, { + completionRedirect: true, + returnTo: 'search', + completionId: input.oauthCompletionId, + }) + } if (!context.knowledgeBase.isSearchIndex) return invitationLink const url = new URL(invitationLink) url.searchParams.set('optionId', optionId) @@ -102,12 +141,9 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge `Ask an admin to configure ${connectorMeta.name} sign-in in Connected accounts` ) } - const { invitationLink: url } = await createViewerCredentialGroupEnrollment({ - userId, - ...owner, - credentialGroupId: binding.credentialGroupId, - }) - return { url: enrollmentUrl(url, binding.credentialGroupOptionId) } + return { + url: await enrollmentUrl(binding.credentialGroupId, binding.credentialGroupOptionId), + } } if ( connector.accessMode !== 'members' || @@ -140,12 +176,9 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge sourceConfig: connector.sourceConfig, }) if (!validation.ok) throw new OrchestrationError('validation', validation.message) - const { invitationLink: url } = await createViewerCredentialGroupEnrollment({ - userId, - ...owner, - credentialGroupId: connector.credentialGroupId, - }) - return { url: enrollmentUrl(url, connector.credentialGroupOptionId) } + return { + url: await enrollmentUrl(connector.credentialGroupId, connector.credentialGroupOptionId), + } }, }) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts index bba1e96d990..043fe0f5f8d 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts @@ -17,10 +17,12 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ resolveKnowledgeAccessAvailability: mocks.availability, })) vi.mock('@/lib/sim-search/connectors', () => ({ + canConnectWithDefaults: (meta: { id: string }) => ['google_drive', 'gmail'].includes(meta.id), SEARCH_SOURCE_TYPES: [ - ['google_drive', { mirrorsSourceAcls: true }], - ['gmail', { permissionScopedListing: {} }], - ['github', { permissionScopedListing: {} }], + ['google_drive', { id: 'google_drive', mirrorsSourceAcls: true, permissionScopedListing: {} }], + ['gmail', { id: 'gmail', permissionScopedListing: {} }], + ['github', { id: 'github', permissionScopedListing: {} }], + ['gitlab', { id: 'gitlab', mirrorsSourceAcls: true }], ], })) @@ -35,6 +37,8 @@ const health = { sourceCount: 4, pausedCount: 0, hasError: false, + hasAccountError: false, + hasDocumentError: false, hasIndexing: false, hasWaiting: false, hasUnstarted: false, @@ -49,6 +53,35 @@ beforeEach(() => { }) describe('organization Search administration overview', () => { + it.each([ + { connectorType: 'google_drive', memberScoped: true, status: 'waiting_for_connections' }, + { connectorType: 'google_drive', memberScoped: false, status: 'needs_setup' }, + { connectorType: 'gitlab', memberScoped: true, status: 'needs_setup' }, + ])( + 'reports $connectorType setup with member access $memberScoped', + async ({ connectorType, memberScoped, status }) => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(organizationSearchIntegration, [{ connectorType, approved: true }]) + mocks.availability.mockResolvedValue({ memberScoped, sourceMirrored: true }) + const result = await readOrganizationSearchOverview.execute({ principal, input }) + expect(result.providers).toEqual([ + { connectorType, approved: true, sourceCount: 0, status, issue: null, isSyncing: false }, + ]) + } + ) + it.each([ + { hasAccountError: true, hasDocumentError: false, issue: 'account_sync_incomplete' }, + { hasAccountError: false, hasDocumentError: true, issue: 'document_indexing_failed' }, + { hasAccountError: false, hasDocumentError: false, issue: 'sync_failed' }, + ])('identifies $issue without exposing error details', async ({ issue, ...errors }) => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(knowledgeConnector, [ + { ...health, ...errors, hasError: true, rawError: 'private provider response' }, + ]) + const result = await readOrganizationSearchOverview.execute({ principal, input }) + expect(result.providers[0]).toMatchObject({ status: 'needs_attention', issue }) + expect(JSON.stringify(result)).not.toContain('private provider response') + }) it('keeps recovery observable while a previous error remains visible', async () => { queueTableRows(member, [{ role: 'admin' }]) queueTableRows(knowledgeConnector, [{ ...health, hasError: true, hasIndexing: true }]) @@ -59,6 +92,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'needs_attention', + issue: 'sync_failed', isSyncing: true, }, ]) @@ -84,6 +118,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'active', + issue: null, isSyncing: false, }, { @@ -91,6 +126,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: true, status: 'waiting_for_connections', + issue: null, isSyncing: false, }, { @@ -98,6 +134,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: false, status: 'paused', + issue: null, isSyncing: false, }, ], @@ -149,6 +186,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: false, status: 'paused', + issue: null, isSyncing: false, }, ]) @@ -181,6 +219,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'paused', + issue: null, isSyncing: false, }, { @@ -188,6 +227,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: true, status: 'paused', + issue: null, isSyncing: false, }, ]) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.ts b/apps/sim/lib/knowledge/application/organization-search-overview.ts index 6031da63c09..70025fc809b 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.ts @@ -16,7 +16,7 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants' -import { SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { canConnectWithDefaults, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' interface OrganizationSearchOverviewInput { organizationId: string @@ -26,6 +26,8 @@ interface ProviderHealth { sourceCount: number pausedCount: number hasError: boolean + hasAccountError: boolean + hasDocumentError: boolean hasIndexing: boolean hasWaiting: boolean hasUnstarted: boolean @@ -35,12 +37,12 @@ interface ProviderHealth { function organizationSearchProviderStatus( health: ProviderHealth | undefined, approved: boolean, - mirrorsSourceAcls: boolean, + automaticSetup: boolean, available: boolean ) { if (!approved || !available) return 'paused' as const if (!health?.sourceCount) - return mirrorsSourceAcls ? ('needs_setup' as const) : ('waiting_for_connections' as const) + return automaticSetup ? ('waiting_for_connections' as const) : ('needs_setup' as const) if (health.pausedCount === health.sourceCount) return 'paused' as const if (health.hasError) return 'needs_attention' as const if (health.hasIndexing) return 'indexing' as const @@ -194,6 +196,8 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ OR ${hasMemberError} OR ${latestMemberRunHasError} )) ))`, + hasAccountError: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'members' AND ${hasMemberError})`, + hasDocumentError: sql`bool_or(NOT ${paused} AND ${hasDocumentsInState(['failed'])})`, hasIndexing: sql`bool_or(NOT ${paused} AND (${knowledgeConnector.accessMode} <> 'members' OR ${hasActiveMembers} OR ${knowledgeConnector.credentialId} IS NOT NULL) AND ( @@ -247,7 +251,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ const status = organizationSearchProviderStatus( state, approved, - meta.mirrorsSourceAcls === true, + canConnectWithDefaults(meta) && availability.memberScoped, Boolean( (meta.permissionScopedListing && availability.memberScoped) || (meta.mirrorsSourceAcls && @@ -261,6 +265,14 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ approved, sourceCount: state?.sourceCount ?? 0, status, + issue: + status === 'needs_attention' + ? state?.hasAccountError + ? ('account_sync_incomplete' as const) + : state?.hasDocumentError + ? ('document_indexing_failed' as const) + : ('sync_failed' as const) + : null, isSyncing: status !== 'paused' && Boolean(state?.hasIndexing), }, ] diff --git a/apps/sim/lib/knowledge/application/search-sources.test.ts b/apps/sim/lib/knowledge/application/search-sources.test.ts index 7780bf13f9b..d806ccb77b5 100644 --- a/apps/sim/lib/knowledge/application/search-sources.test.ts +++ b/apps/sim/lib/knowledge/application/search-sources.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ permission: vi.fn(), availability: vi.fn(), memberships: vi.fn(), + accounts: vi.fn(), access: vi.fn(), predicate: vi.fn(), })) @@ -36,6 +37,9 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ resolveViewerConnectorMemberships: mocks.memberships, })) +vi.mock('@/lib/knowledge/connectors/viewer-source-accounts', () => ({ + resolveViewerSourceAccounts: mocks.accounts, +})) vi.mock('@/lib/knowledge/access/scope', () => ({ createKnowledgeAccessProvider: mocks.access, })) @@ -114,6 +118,7 @@ beforeEach(() => { mocks.permission.mockResolvedValue('read') mocks.availability.mockResolvedValue({ sourceMirrored: true, memberScoped: true }) mocks.memberships.mockResolvedValue(new Map()) + mocks.accounts.mockResolvedValue(new Map()) mocks.access.mockReturnValue({ get: async () => access, getForConnectors: async () => access, @@ -145,6 +150,7 @@ describe('Search source summaries', () => { viewerDocumentCount: 4, viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: false, viewerMembership: null, }, @@ -376,6 +382,30 @@ describe('Search source summaries', () => { }) describe('organization Search source summaries', () => { + it.each(['syncing', 'error', 'paused', 'disabled'])( + 'keeps own %s accounts removable even when setup is unavailable', + async (status) => { + mocks.context.mockResolvedValue({ organizationId: 'org-1' }) + queueTableRows(member, [{ role: 'member' }]) + seed([{ ...source('own', 'google_drive', 'members'), status }, source('someone-else')], false) + mocks.availability.mockResolvedValue({ sourceMirrored: false, memberScoped: false }) + const account = { credentialId: 'own-account', displayName: 'My Drive' } + mocks.accounts.mockResolvedValue(new Map([['own', [account]]])) + const result = await listSearchSources.execute({ + principal, + input: { organizationId: 'org-1', mine: true }, + }) + expect(result.sources).toHaveLength(1) + expect(result.sources[0]).toMatchObject({ + connectorId: 'own', + availability: 'unavailable', + viewerAccounts: [account], + }) + expect(mocks.accounts).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-1', userId: 'reader' }) + ) + } + ) it.each(['member', 'admin'])( 'returns only the current %s viewer ACL counts without a workspace membership', async (role) => { @@ -587,12 +617,16 @@ describe('bounded Search source pagination', () => { seed(candidates) mocks.memberships.mockResolvedValue( new Map([ + ['source-093', 'invited'], + ['source-094', 'not_enrolled'], + ['source-095', 'revoked'], + ['source-096', 'unverified_email'], ['source-097', 'connected'], ['source-098', 'needs_reauth'], ]) ) const result = await listSearchSources.execute({ principal, input: { ...input, mine: true } }) - expect(result.sources.map((row) => row.connectorId)).toEqual(['source-097']) + expect(result.sources.map((row) => row.connectorId)).toEqual(['source-097', 'source-098']) expect(result.nextCursor).toBeNull() }) diff --git a/apps/sim/lib/knowledge/application/search-sources.ts b/apps/sim/lib/knowledge/application/search-sources.ts index a639708d49d..b41e7a80270 100644 --- a/apps/sim/lib/knowledge/application/search-sources.ts +++ b/apps/sim/lib/knowledge/application/search-sources.ts @@ -16,6 +16,7 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { resolveViewerConnectorMemberships } from '@/lib/knowledge/connectors/member-provisioning' +import { resolveViewerSourceAccounts } from '@/lib/knowledge/connectors/viewer-source-accounts' import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE, SEARCH_SOURCE_PAGE_SIZE, @@ -106,7 +107,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ if (candidates.length === 0) return { sources: [], nextCursor: null } const scanned = candidates.slice(0, SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) - const [availability, memberships, viewers, approvals] = await Promise.all([ + const [availability, memberships, viewers, approvals, accounts] = await Promise.all([ resolveKnowledgeAccessAvailability(context), resolveViewerConnectorMemberships({ userId: principal.userId, @@ -120,10 +121,24 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ .where(eq(user.id, principal.userId)) .limit(1), context.organizationId ? listOrganizationSearchApprovals(context.organizationId) : null, + context.organizationId + ? resolveViewerSourceAccounts({ + organizationId: context.organizationId, + userId: principal.userId, + connectors: scanned, + }) + : new Map(), ]) - /** Filtering uses the same safe display labels and verified membership as the source rows. */ + /** Owned grants stay manageable even when the source can no longer authorize Search. */ const matches = scanned.filter((row) => { - if (input.mine && memberships.get(row.id) !== 'connected') return false + const membership = memberships.get(row.id) + if ( + input.mine && + (context.organizationId + ? !accounts.has(row.id) + : membership !== 'connected' && membership !== 'needs_reauth') + ) + return false const meta = getConnectorMeta(row.connectorType) const label = meta ? `${meta.name ?? row.connectorType} ${describeSearchSource(meta, row.sourceConfig)}` @@ -224,6 +239,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ viewerDocumentCount: available ? (state?.count ?? 0) : 0, viewerFailedDocumentCount: available ? (state?.failedCount ?? 0) : 0, viewerEmailVerified: viewers[0]?.emailVerified === true, + viewerAccounts: accounts.get(row.id) ?? [], } as const return [ { diff --git a/apps/sim/lib/knowledge/application/sim-search.test.ts b/apps/sim/lib/knowledge/application/sim-search.test.ts index 2f919062d40..a0e521ba71b 100644 --- a/apps/sim/lib/knowledge/application/sim-search.test.ts +++ b/apps/sim/lib/knowledge/application/sim-search.test.ts @@ -509,7 +509,11 @@ describe('organization Search setup', () => { await expect( connectSimSearchConnector.execute({ principal, - input: { ...owner, connectorType: 'google_drive' }, + input: { + ...owner, + connectorType: 'google_drive', + oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000', + }, }) ).resolves.toMatchObject(existingConnector) expect(mocks.enroll).toHaveBeenCalledWith( @@ -518,6 +522,7 @@ describe('organization Search setup', () => { input: expect.objectContaining({ assertedOrganizationId: 'org-1', connectorId: 'connector-drive', + oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000', }), }) ) diff --git a/apps/sim/lib/knowledge/application/sim-search.ts b/apps/sim/lib/knowledge/application/sim-search.ts index b2b2f8a9ade..a8b1c94c5d8 100644 --- a/apps/sim/lib/knowledge/application/sim-search.ts +++ b/apps/sim/lib/knowledge/application/sim-search.ts @@ -64,12 +64,14 @@ export interface ConnectSimSearchConnectorInput extends ResourceOwner { connectorId?: string /** Source settings identify a compatible configuration when creating or reusing a source. */ sourceConfig?: Record + /** Correlates a direct provider authorization with the initiating Integrations tab. */ + oauthCompletionId?: string } export interface ConnectSimSearchConnectorResult { knowledgeBaseId: string connectorId: string - /** The enrollment link that connects the caller's own account. */ + /** The invitation link or provider authorization URL for the caller's own account. */ url: string } @@ -367,6 +369,7 @@ export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({ connectorId: target.connectorId, assertedWorkspaceId: workspaceId, assertedOrganizationId: context.organizationId, + oauthCompletionId: input.oauthCompletionId, }, request, }) diff --git a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts new file mode 100644 index 00000000000..195511f6601 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts @@ -0,0 +1,98 @@ +/** @vitest-environment node */ +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { eq, inArray, isNull } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/connectors/registry', () => ({ + getConnectorMeta: (id: string) => ({ + requiresMemberIdentity: id === 'slack', + auth: { mode: 'oauth', provider: id }, + }), +})) + +import { resolveViewerSourceAccounts } from '@/lib/knowledge/connectors/viewer-source-accounts' +import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE } from '@/lib/knowledge/constants' + +const source = { + id: 'gmail-source', + connectorType: 'gmail', + accessMode: 'members', + credentialGroupId: 'group-1', + credentialGroupOptionId: 'gmail-option', +} +const input = { organizationId: 'org-1', userId: 'viewer', connectors: [source] } +const account = { + credentialId: 'mine', + displayName: 'My Gmail', + groupId: 'group-1', + optionId: 'gmail-option', + providerId: 'gmail', +} + +describe('personal source account projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('binds the current contributor and organization on both credentials and groups', async () => { + queueTableRows(credential, [account]) + const result = await resolveViewerSourceAccounts(input) + expect(eq).toHaveBeenCalledWith(credentialGroupEnrollment.userId, 'viewer') + expect(eq).toHaveBeenCalledWith(credential.organizationId, 'org-1') + expect(eq).toHaveBeenCalledWith(credentialGroup.organizationId, 'org-1') + expect(isNull).toHaveBeenCalledWith(credential.workspaceId) + expect(isNull).toHaveBeenCalledWith(credentialGroup.workspaceId) + expect(isNull).toHaveBeenCalledWith(credential.revokedAt) + expect(inArray).toHaveBeenCalledWith(credential.managedOauthStatus, ['active', 'needs_reauth']) + expect(result.get(source.id)).toEqual([{ credentialId: 'mine', displayName: 'My Gmail' }]) + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + credentialId: credential.id, + displayName: credential.displayName, + groupId: credentialGroup.id, + optionId: credential.credentialGroupOptionId, + providerId: credential.providerId, + }) + }) + + it('does not attach an account from another source option or group', async () => { + queueTableRows(credential, [ + { ...account, groupId: 'other' }, + { ...account, optionId: 'other' }, + ]) + expect(await resolveViewerSourceAccounts(input)).toEqual(new Map()) + }) + + it('maps Slack personal identity accounts without offering its administrative bot credential', async () => { + queueTableRows(credential, [ + { ...account, providerId: 'slack', credentialId: 'slack-personal' }, + ]) + const result = await resolveViewerSourceAccounts({ + ...input, + connectors: [ + { + ...source, + id: 'slack-source', + connectorType: 'slack', + accessMode: 'admin', + credentialGroupId: null, + credentialGroupOptionId: null, + }, + ], + }) + expect(eq).toHaveBeenCalledWith(credential.type, 'managed_oauth') + expect(eq).toHaveBeenCalledWith(credential.providerId, 'slack') + expect(result.get('slack-source')).toEqual([ + { credentialId: 'slack-personal', displayName: 'My Gmail' }, + ]) + }) + + it('fails instead of silently truncating too many accounts', async () => { + queueTableRows( + credential, + Array.from({ length: SEARCH_SOURCE_CANDIDATE_PAGE_SIZE + 1 }, () => account) + ) + await expect(resolveViewerSourceAccounts(input)).rejects.toThrow('Too many personal accounts') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts new file mode 100644 index 00000000000..84a501a0c75 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts @@ -0,0 +1,97 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE } from '@/lib/knowledge/constants' +import { getConnectorMeta } from '@/connectors/registry' + +interface ViewerSourceAccount { + credentialId: string + displayName: string +} + +interface SourceAccountBinding { + id: string + connectorType: string + accessMode: string + credentialGroupId: string | null + credentialGroupOptionId: string | null +} + +/** + * Own account controls remain available when provider setup, enrollment, or sync is disabled. + * Called inside the authorized source read; selects no token material or other contributors. + */ +export async function resolveViewerSourceAccounts(input: { + organizationId: string + userId: string + connectors: ReadonlyArray +}): Promise> { + const bindings = input.connectors.map((source) => { + const meta = getConnectorMeta(source.connectorType) + const providerId = + source.accessMode === 'admin' && meta?.requiresMemberIdentity && meta.auth.mode === 'oauth' + ? meta.auth.provider + : null + return { source, providerId } + }) + const matches = bindings.flatMap(({ source, providerId }) => { + if ( + source.accessMode === 'members' && + source.credentialGroupId && + source.credentialGroupOptionId + ) + return [ + and( + eq(credentialGroup.id, source.credentialGroupId), + eq(credential.credentialGroupOptionId, source.credentialGroupOptionId) + ), + ] + return providerId ? [eq(credential.providerId, providerId)] : [] + }) + const result = new Map() + if (!matches.length) return result + const scope = { kind: 'organization', organizationId: input.organizationId } as const + const accounts = await db + .select({ + credentialId: credential.id, + displayName: credential.displayName, + groupId: credentialGroup.id, + optionId: credential.credentialGroupOptionId, + providerId: credential.providerId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + resourceScopeCondition(credential, scope), + resourceScopeCondition(credentialGroup, scope), + eq(credentialGroupEnrollment.userId, input.userId), + eq(credential.type, 'managed_oauth'), + inArray(credential.managedOauthStatus, ['active', 'needs_reauth']), + isNull(credential.revokedAt), + or(...matches) + ) + ) + .limit(SEARCH_SOURCE_CANDIDATE_PAGE_SIZE + 1) + if (accounts.length > SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) + throw new Error('Too many personal accounts for the source page') + for (const { source, providerId } of bindings) { + const own = accounts.filter((account) => + source.accessMode === 'members' + ? account.groupId === source.credentialGroupId && + account.optionId === source.credentialGroupOptionId + : providerId !== null && account.providerId === providerId + ) + if (own.length) + result.set( + source.id, + own.map(({ credentialId, displayName }) => ({ credentialId, displayName })) + ) + } + return result +} diff --git a/apps/sim/lib/sim-search/connectors.test.ts b/apps/sim/lib/sim-search/connectors.test.ts index 07bbd390c59..254484c8cfe 100644 --- a/apps/sim/lib/sim-search/connectors.test.ts +++ b/apps/sim/lib/sim-search/connectors.test.ts @@ -117,13 +117,16 @@ vi.mock('@/lib/credential-groups/providers', () => ({ import { canConnectPersonally, + canConnectWithDefaults, getConnectorAccessAvailability, isSearchConnectorAvailable, missingSetupFields, personalSetupFields, SEARCH_CONNECTORS, } from '@/lib/sim-search/connectors' +import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { slackConnectorMeta } from '@/connectors/slack/meta' import type { ConnectorMeta } from '@/connectors/types' describe('SEARCH_CONNECTORS', () => { @@ -161,6 +164,21 @@ describe('canConnectPersonally', () => { }) describe('personalSetupFields', () => { + it('does not require central indexing setup for a personal Drive connection', () => { + const defaults = CONNECTOR_META_REGISTRY.google_drive + expect(canConnectWithDefaults(defaults)).toBe(true) + expect(canConnectWithDefaults(googleDriveConnectorMeta)).toBe(true) + expect(canConnectWithDefaults(slackConnectorMeta)).toBe(false) + expect( + canConnectWithDefaults({ + ...defaults, + permissionScopedListing: undefined, + mirrorsSourceAcls: true, + }) + ).toBe(false) + expect(canConnectWithDefaults(CONNECTOR_META_REGISTRY.jira)).toBe(false) + expect(canConnectWithDefaults(CONNECTOR_META_REGISTRY.unreviewed)).toBe(false) + }) it('asks for required config beyond the listing caps, never a selector', () => { const drive = SEARCH_CONNECTORS.find((connector) => connector.type === 'google_drive')! const jira = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira')! diff --git a/apps/sim/lib/sim-search/connectors.ts b/apps/sim/lib/sim-search/connectors.ts index 991f1d52cd3..d5ce4c1c348 100644 --- a/apps/sim/lib/sim-search/connectors.ts +++ b/apps/sim/lib/sim-search/connectors.ts @@ -115,6 +115,11 @@ export function personalSetupFields(meta: ConnectorMeta): ConnectorConfigField[] ) } +/** Personal sources use defaults even when they also support central indexing. Slack needs a custom app first. */ +export function canConnectWithDefaults(meta: ConnectorMeta): boolean { + return canConnectPersonally(meta) && meta.id !== 'slack' && personalSetupFields(meta).length === 0 +} + /** The setup fields a source config leaves empty. */ export function missingSetupFields( meta: ConnectorMeta, From ccc6f9ed80b5da4a00764034440680a8a0ff9050 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 18:03:08 -0700 Subject: [PATCH 11/30] feat(workspace-sync): add portable imports and v2 fork workflows (#7700) * feat(workspace-sync): add portable imports and v2 fork workflows * fix(workspace-sync): preserve activity attribution and update boundary tests --- .../(generated)/workspace-sync/meta.json | 24 + .../docs/content/docs/api-reference/meta.json | 2 + .../docs/api-reference/workflow-sync.mdx | 238 + apps/docs/content/docs/cli/commands.mdx | 1 + apps/docs/content/docs/cli/meta.json | 2 + apps/docs/content/docs/cli/reference.mdx | 456 +- apps/docs/content/docs/cli/selectors.mdx | 50 + apps/docs/content/docs/cli/workflow-sync.mdx | 162 + apps/docs/content/docs/cli/workflows.mdx | 41 +- apps/docs/content/docs/cli/workspaces.mdx | 363 + apps/docs/lib/openapi-download.test.ts | 3 +- apps/docs/openapi-v2-workflows.json | 16616 ++++++---- apps/sim/app/api/v2/selectors/get/route.ts | 22 + apps/sim/app/api/v2/selectors/list/route.ts | 17 + .../v2/workflows/[workflowId]/export/route.ts | 5 +- .../api/v2/workflows/import/preview/route.ts | 18 + apps/sim/app/api/v2/workflows/import/route.ts | 11 +- .../[workspaceId]/fork/availability/route.ts | 16 + .../[workspaceId]/fork/children/route.ts | 16 + .../[workspaceId]/fork/exclusions/route.ts | 17 + .../[workspaceId]/fork/lineage/route.ts | 16 + .../[workspaceId]/fork/mappings/route.ts | 32 + .../[workspaceId]/fork/preview/route.ts | 17 + .../[workspaceId]/fork/pull/preview/route.ts | 25 + .../[workspaceId]/fork/pull/route.ts | 25 + .../[workspaceId]/fork/push/preview/route.ts | 25 + .../[workspaceId]/fork/push/route.ts | 25 + .../[workspaceId]/fork/resources/route.ts | 16 + .../[workspaceId]/fork/rollback/route.ts | 17 + .../v2/workspaces/[workspaceId]/fork/route.ts | 17 + .../[workspaceId]/fork/unlink/route.ts | 17 + .../operations/[operationId]/route.ts | 16 + .../[workspaceId]/operations/route.ts | 16 + .../app/api/webhooks/outbox/process/route.ts | 4 + .../[id]/fork/availability/route.ts | 52 +- .../api/workspaces/[id]/fork/diff/route.ts | 304 +- .../fork/excluded-workflows/route.test.ts | 43 +- .../[id]/fork/excluded-workflows/route.ts | 112 +- .../[id]/fork/lineage/route.test.ts | 47 +- .../api/workspaces/[id]/fork/lineage/route.ts | 100 +- .../api/workspaces/[id]/fork/mapping/route.ts | 134 +- .../[id]/fork/promote/route.test.ts | 45 +- .../api/workspaces/[id]/fork/promote/route.ts | 151 +- .../workspaces/[id]/fork/resources/route.ts | 42 +- .../workspaces/[id]/fork/rollback/route.ts | 110 +- .../api/workspaces/[id]/fork/route.test.ts | 50 +- .../sim/app/api/workspaces/[id]/fork/route.ts | 106 +- .../api/workspaces/[id]/fork/unlink/route.ts | 65 +- apps/sim/blocks/blocks/mcp.ts | 1 + .../workspace-forking/api/route-policies.ts | 49 + .../application/admit-sync.ts | 126 + .../application/authorized-fork-use-case.ts | 72 + .../application/content-outbox.test.ts | 200 + .../application/content-outbox.ts | 251 + .../application/create-and-sync.ts | 296 + .../application/discovery.test.ts | 133 + .../application/discovery.ts | 228 + .../application/lineage-details.ts | 76 + .../application/mapping-details.ts | 39 + .../application/operations.ts | 100 + .../application/preview-sync.test.ts | 427 + .../application/preview-sync.ts | 329 + .../application/recovery-and-mappings.test.ts | 219 + .../application/recovery-and-mappings.ts | 246 + .../application/resource-details.ts | 11 + .../workspace-forking/application/revision.ts | 199 + .../application/sync-details.ts | 293 + .../application/validate-bindings.ts | 45 + .../components/fork-sync/use-fork-sync.ts | 1 + .../lib/copy/cleanup-failed.test.ts | 2 +- .../lib/copy/cleanup-failed.ts | 10 +- .../lib/copy/content-copy-runner.test.ts | 23 + .../lib/copy/content-copy-runner.ts | 49 +- .../workspace-forking/lib/copy/copy-files.ts | 24 +- .../lib/copy/copy-resources.test.ts | 170 +- .../lib/copy/copy-resources.ts | 175 +- .../lib/copy/copy-workflows.test.ts | 103 + .../lib/copy/copy-workflows.ts | 50 +- .../lib/copy/deploy-bridge.ts | 122 +- .../lib/copy/progress.test.ts | 89 + .../ee/workspace-forking/lib/copy/progress.ts | 77 + .../workspace-forking/lib/create-fork.test.ts | 2 +- .../ee/workspace-forking/lib/create-fork.ts | 127 +- .../ee/workspace-forking/lib/lineage/authz.ts | 12 +- .../lib/mapping/cascade.test.ts | 4 +- .../workspace-forking/lib/mapping/cascade.ts | 2 +- .../lib/mapping/dependent-value-store.test.ts | 2 +- .../lib/mapping/dependent-value-store.ts | 2 +- .../lib/mapping/mapping-service.test.ts | 10 +- .../lib/mapping/mapping-service.ts | 102 +- .../lib/mapping/mapping-store.ts | 4 +- .../lib/mapping/resources.test.ts | 2 +- .../lib/promote/cleared-refs.test.ts | 4 +- .../lib/promote/cleared-refs.ts | 28 +- .../lib/promote/copy-unmapped.test.ts | 4 +- .../lib/promote/copy-unmapped.ts | 12 +- .../lib/promote/promote-plan.test.ts | 6 +- .../lib/promote/promote-plan.ts | 33 +- .../lib/promote/promote.test.ts | 38 +- .../workspace-forking/lib/promote/promote.ts | 1276 +- .../lib/promote/trigger-urls.test.ts | 103 + .../lib/promote/trigger-urls.ts | 69 +- .../lib/remap/fork-bootstrap.ts | 14 +- .../lib/remap/remap-block-type.test.ts | 4 +- .../lib/api/contracts/selectors/execute.ts | 16 +- .../v2/__tests__/list-pagination.test.ts | 19 + .../lib/api/contracts/v2/openapi/workflows.ts | 26 +- .../contracts/v2/openapi/workspace-sync.ts | 731 + apps/sim/lib/api/contracts/v2/selectors.ts | 103 + apps/sim/lib/api/contracts/v2/workflows.ts | 277 +- .../api/contracts/v2/workspace-fork.test.ts | 79 + .../lib/api/contracts/v2/workspace-fork.ts | 770 + .../api/contracts/v2/workspace-operations.ts | 174 + .../lib/api/contracts/workflow-references.ts | 90 + apps/sim/lib/api/contracts/workspace-fork.ts | 32 +- .../lib/copilot/generated/docs-manifest.ts | 2 + .../authorized-workspace-use-case.ts | 4 +- apps/sim/lib/core/orchestration/types.ts | 1 + apps/sim/lib/mcp/workflow-mcp-sync.ts | 46 +- apps/sim/lib/secrets/references/scan.ts | 2 +- apps/sim/lib/selectors/api/error-policy.ts | 31 + .../selectors/application/execute-selector.ts | 16 +- .../application/get-selector-option.ts | 61 + .../lib/selectors/application/operations.ts | 3 +- .../selectors/application/paged-selector.ts | 109 + apps/sim/lib/selectors/manifest.test.ts | 9 +- apps/sim/lib/selectors/manifest.ts | 8 + apps/sim/lib/selectors/server/credentials.ts | 12 +- apps/sim/lib/selectors/server/internal.ts | 30 +- .../selectors/server/providers/mcp.test.ts | 71 + .../sim/lib/selectors/server/providers/mcp.ts | 53 + apps/sim/lib/selectors/server/registry.ts | 2 + apps/sim/lib/selectors/server/types.ts | 9 +- apps/sim/lib/selectors/types.ts | 1 + apps/sim/lib/workflows/api/route-policies.ts | 3 +- .../application/import-export.test.ts | 2 +- .../workflows/application/import-export.ts | 29 +- .../workflows/application/mapped-import.ts | 344 + .../lib/workflows/application/operations.ts | 11 + .../credentials/credential-extractor.ts | 33 +- apps/sim/lib/workflows/deployment-outbox.ts | 45 + .../operations/export-workflow.test.ts | 246 +- .../workflows/operations/export-workflow.ts | 71 +- .../workflows/operations/import-workflow.ts | 2 +- .../sim/lib/workflows/orchestration/deploy.ts | 47 +- .../orchestration/workflow-lifecycle.ts | 48 +- .../persistence/remap-internal-ids.ts | 44 +- apps/sim/lib/workflows/persistence/utils.ts | 33 +- .../workflows/references/binding-targets.ts | 357 + .../custom-block-reconfigs.test.ts | 4 +- .../references}/custom-block-reconfigs.ts | 8 +- .../references}/dependent-reconfigs.test.ts | 6 +- .../references}/dependent-reconfigs.ts | 75 +- .../workflows/references/finalize-import.ts | 30 + .../references/finalize-tool-positions.ts | 36 + .../references/import-configuration.test.ts | 113 + .../references/import-configuration.ts | 199 + .../workflows/references/import-plan.test.ts | 315 + .../lib/workflows/references/import-plan.ts | 462 + .../lib/workflows/references/inline-tools.ts | 106 + .../lib/workflows/references/manifest.test.ts | 269 + apps/sim/lib/workflows/references/manifest.ts | 181 + .../references/preview-limits.test.ts | 21 + .../workflows/references/preview-limits.ts | 13 + .../workflows/references}/reference-scan.ts | 4 +- .../workflows/references}/remap-files.test.ts | 2 +- .../workflows/references}/remap-files.ts | 7 + .../references}/remap-references.test.ts | 6 +- .../workflows/references}/remap-references.ts | 393 +- .../workflows/references}/resources.ts | 362 +- .../references/selector-values.test.ts | 79 + .../workflows/references/selector-values.ts | 71 + apps/sim/lib/workflows/references/types.ts | 41 + .../workflows/sanitization/json-sanitizer.ts | 6 +- .../search-replace/resources/registry.ts | 19 + apps/sim/lib/workflows/variables/parse.ts | 9 +- .../lib/workspaces/__integration__/README.md | 17 + .../__integration__/fork-sync.integration.ts | 666 + .../__integration__/http-cli.integration.ts | 335 + .../mapped-import.integration.ts | 262 + .../__integration__/pagination.integration.ts | 167 + .../__integration__/receipts.integration.ts | 131 + .../lib/workspaces/operations/application.ts | 127 + .../lib/workspaces/operations/operations.ts | 15 + apps/sim/lib/workspaces/operations/outbox.ts | 30 + .../sim/lib/workspaces/operations/receipts.ts | 173 + .../lib/workspaces/operations/refresh.test.ts | 161 + apps/sim/lib/workspaces/operations/refresh.ts | 161 + .../vitest.workflows-integration.config.ts | 17 + .../sim/vitest.workflows-integration.setup.ts | 67 + package.json | 1 + .../0337_colossal_the_renegades.sql | 15 + .../db/migrations/meta/0337_snapshot.json | 26041 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 29 + .../sim-cli/src/commands/protocol/index.ts | 3 + .../protocol/workspace-operation-wait.test.ts | 175 + .../protocol/workspace-operation-wait.ts | 185 + packages/sim-cli/src/contract/commands.ts | 65 +- packages/sim-cli/src/contract/types.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 2371 +- packages/sim-cli/src/http/client.test.ts | 30 + packages/sim-cli/src/http/client.ts | 19 +- packages/sim-cli/src/index.ts | 24 +- packages/sim-cli/src/runtime/execute.test.ts | 141 +- packages/sim-cli/src/runtime/execute.ts | 131 +- packages/sim-cli/src/runtime/options.ts | 11 + packages/sim-cli/src/runtime/request.ts | 51 +- packages/sim-cli/src/runtime/result.test.ts | 60 +- packages/sim-cli/src/runtime/result.ts | 37 +- scripts/check-openapi-specs.ts | 4 + scripts/generate-cli-docs.ts | 1 + scripts/openapi/documents.test.ts | 5 +- scripts/test-workflow-sync.ts | 106 + 214 files changed, 56804 insertions(+), 7782 deletions(-) create mode 100644 apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json create mode 100644 apps/docs/content/docs/api-reference/workflow-sync.mdx create mode 100644 apps/docs/content/docs/cli/selectors.mdx create mode 100644 apps/docs/content/docs/cli/workflow-sync.mdx create mode 100644 apps/sim/app/api/v2/selectors/get/route.ts create mode 100644 apps/sim/app/api/v2/selectors/list/route.ts create mode 100644 apps/sim/app/api/v2/workflows/import/preview/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts create mode 100644 apps/sim/ee/workspace-forking/api/route-policies.ts create mode 100644 apps/sim/ee/workspace-forking/application/admit-sync.ts create mode 100644 apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts create mode 100644 apps/sim/ee/workspace-forking/application/content-outbox.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/content-outbox.ts create mode 100644 apps/sim/ee/workspace-forking/application/create-and-sync.ts create mode 100644 apps/sim/ee/workspace-forking/application/discovery.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/discovery.ts create mode 100644 apps/sim/ee/workspace-forking/application/lineage-details.ts create mode 100644 apps/sim/ee/workspace-forking/application/mapping-details.ts create mode 100644 apps/sim/ee/workspace-forking/application/operations.ts create mode 100644 apps/sim/ee/workspace-forking/application/preview-sync.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/preview-sync.ts create mode 100644 apps/sim/ee/workspace-forking/application/recovery-and-mappings.test.ts create mode 100644 apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts create mode 100644 apps/sim/ee/workspace-forking/application/resource-details.ts create mode 100644 apps/sim/ee/workspace-forking/application/revision.ts create mode 100644 apps/sim/ee/workspace-forking/application/sync-details.ts create mode 100644 apps/sim/ee/workspace-forking/application/validate-bindings.ts create mode 100644 apps/sim/ee/workspace-forking/lib/copy/progress.test.ts create mode 100644 apps/sim/ee/workspace-forking/lib/copy/progress.ts create mode 100644 apps/sim/lib/api/contracts/v2/openapi/workspace-sync.ts create mode 100644 apps/sim/lib/api/contracts/v2/selectors.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspace-fork.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspace-fork.ts create mode 100644 apps/sim/lib/api/contracts/v2/workspace-operations.ts create mode 100644 apps/sim/lib/api/contracts/workflow-references.ts create mode 100644 apps/sim/lib/selectors/api/error-policy.ts create mode 100644 apps/sim/lib/selectors/application/get-selector-option.ts create mode 100644 apps/sim/lib/selectors/application/paged-selector.ts create mode 100644 apps/sim/lib/selectors/server/providers/mcp.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/mcp.ts create mode 100644 apps/sim/lib/workflows/application/mapped-import.ts create mode 100644 apps/sim/lib/workflows/references/binding-targets.ts rename apps/sim/{ee/workspace-forking/lib/mapping => lib/workflows/references}/custom-block-reconfigs.test.ts (96%) rename apps/sim/{ee/workspace-forking/lib/mapping => lib/workflows/references}/custom-block-reconfigs.ts (96%) rename apps/sim/{ee/workspace-forking/lib/mapping => lib/workflows/references}/dependent-reconfigs.test.ts (99%) rename apps/sim/{ee/workspace-forking/lib/mapping => lib/workflows/references}/dependent-reconfigs.ts (90%) create mode 100644 apps/sim/lib/workflows/references/finalize-import.ts create mode 100644 apps/sim/lib/workflows/references/finalize-tool-positions.ts create mode 100644 apps/sim/lib/workflows/references/import-configuration.test.ts create mode 100644 apps/sim/lib/workflows/references/import-configuration.ts create mode 100644 apps/sim/lib/workflows/references/import-plan.test.ts create mode 100644 apps/sim/lib/workflows/references/import-plan.ts create mode 100644 apps/sim/lib/workflows/references/inline-tools.ts create mode 100644 apps/sim/lib/workflows/references/manifest.test.ts create mode 100644 apps/sim/lib/workflows/references/manifest.ts create mode 100644 apps/sim/lib/workflows/references/preview-limits.test.ts create mode 100644 apps/sim/lib/workflows/references/preview-limits.ts rename apps/sim/{ee/workspace-forking/lib/remap => lib/workflows/references}/reference-scan.ts (97%) rename apps/sim/{ee/workspace-forking/lib/remap => lib/workflows/references}/remap-files.test.ts (96%) rename apps/sim/{ee/workspace-forking/lib/remap => lib/workflows/references}/remap-files.ts (90%) rename apps/sim/{ee/workspace-forking/lib/remap => lib/workflows/references}/remap-references.test.ts (99%) rename apps/sim/{ee/workspace-forking/lib/remap => lib/workflows/references}/remap-references.ts (89%) rename apps/sim/{ee/workspace-forking/lib/mapping => lib/workflows/references}/resources.ts (75%) create mode 100644 apps/sim/lib/workflows/references/selector-values.test.ts create mode 100644 apps/sim/lib/workflows/references/selector-values.ts create mode 100644 apps/sim/lib/workflows/references/types.ts create mode 100644 apps/sim/lib/workspaces/__integration__/README.md create mode 100644 apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts create mode 100644 apps/sim/lib/workspaces/__integration__/http-cli.integration.ts create mode 100644 apps/sim/lib/workspaces/__integration__/mapped-import.integration.ts create mode 100644 apps/sim/lib/workspaces/__integration__/pagination.integration.ts create mode 100644 apps/sim/lib/workspaces/__integration__/receipts.integration.ts create mode 100644 apps/sim/lib/workspaces/operations/application.ts create mode 100644 apps/sim/lib/workspaces/operations/operations.ts create mode 100644 apps/sim/lib/workspaces/operations/outbox.ts create mode 100644 apps/sim/lib/workspaces/operations/receipts.ts create mode 100644 apps/sim/lib/workspaces/operations/refresh.test.ts create mode 100644 apps/sim/lib/workspaces/operations/refresh.ts create mode 100644 apps/sim/vitest.workflows-integration.config.ts create mode 100644 apps/sim/vitest.workflows-integration.setup.ts create mode 100644 packages/db/migrations/0337_colossal_the_renegades.sql create mode 100644 packages/db/migrations/meta/0337_snapshot.json create mode 100644 packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts create mode 100644 scripts/test-workflow-sync.ts diff --git a/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json b/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json new file mode 100644 index 00000000000..8cc588facc8 --- /dev/null +++ b/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json @@ -0,0 +1,24 @@ +{ + "pages": [ + "previewWorkflowImport", + "getWorkspaceForkAvailability", + "getWorkspaceForkLineage", + "listWorkspaceForkChildren", + "listWorkspaceForkResources", + "previewWorkspaceFork", + "forkWorkspace", + "getWorkspaceForkMappings", + "updateWorkspaceForkMappings", + "previewWorkspacePush", + "pushWorkspace", + "previewWorkspacePull", + "pullWorkspace", + "rollbackWorkspaceFork", + "unlinkWorkspaceFork", + "updateWorkspaceForkExclusions", + "listSelector", + "getSelector", + "getWorkspaceOperation", + "listWorkspaceOperations" + ] +} diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index 3b8d0a052bf..d6d47878d24 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -5,6 +5,7 @@ "---Getting Started---", "getting-started", "authentication", + "workflow-sync", "---SDKs---", "python", "typescript", @@ -16,6 +17,7 @@ "(generated)/files", "(generated)/knowledge-bases", "(generated)/workspaces", + "(generated)/workspace-sync", "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", diff --git a/apps/docs/content/docs/api-reference/workflow-sync.mdx b/apps/docs/content/docs/api-reference/workflow-sync.mdx new file mode 100644 index 00000000000..dd0fe5249e0 --- /dev/null +++ b/apps/docs/content/docs/api-reference/workflow-sync.mdx @@ -0,0 +1,238 @@ +--- +title: Workflow imports and workspace sync +description: Use the v2 preview, mapping, apply, and operation-status protocol from an API or CLI client +--- + +An automated client exports or inspects the source, previews destination choices, applies the reviewed request, and polls its operation. The [CLI workflow guide](/cli/workflow-sync) follows this same v2 protocol; the server owns authorization, remapping, atomic writes, and durable completion for both surfaces. + +## CLI and HTTP interfaces + +Paths below are relative to `/api/v2`. `{workspaceId}` is the explicitly selected current workspace. + +| CLI command | HTTP request | +| --- | --- | +| `workflows export --include-references` | `GET /workflows/{workflowId}/export?includeReferences=true` | +| `workflows import-preview` / `workflows import` | `POST /workflows/import/preview` / `POST /workflows/import` | +| `workspaces fork-availability` / `lineage` | `GET /workspaces/{workspaceId}/fork/availability` / `.../fork/lineage` | +| `workspaces children` / `fork-resources` | `GET /workspaces/{workspaceId}/fork/children` / `.../fork/resources?kind=tables` | +| `workspaces fork-preview` / `fork` | `POST /workspaces/{workspaceId}/fork/preview` / `.../fork` | +| `workspaces push-preview` / `push --yes` | `POST /workspaces/{workspaceId}/fork/push/preview` / `.../fork/push` | +| `workspaces pull-preview` / `pull --yes` | `POST /workspaces/{workspaceId}/fork/pull/preview` / `.../fork/pull` | +| `workspaces mappings get` / `mappings update` | `GET` / `PUT /workspaces/{workspaceId}/fork/mappings` | +| `selectors list` / `selectors get` | `POST /selectors/list` / `POST /selectors/get` | +| `workspaces operations get ` / `operations wait ` | `GET /workspaces/{workspaceId}/operations/{operationId}`; wait polls this endpoint | +| `workspaces operations list` | `GET /workspaces/{workspaceId}/operations` | + +The generated [sync preview reference](/api-reference/workspace-sync/previewWorkspacePull) documents every field; its sidebar includes rollback, unlink, and exclusion endpoints. Children, resource discovery, mappings, and operations are paginated; follow `nextCursor` without changing the query's scope or filters. + +## Permissions + +Send a personal API key in `X-API-Key`, or an OAuth access token in `Authorization: Bearer …`. Existing workspace policies, credential access, permission groups, Enterprise/self-hosting gates, and workspace creation limits still apply. + +| Operation | Required access | +| --- | --- | +| Export | Workflow read access | +| Import preview and apply | Destination write access; binding credentials also requires an acting user with credential access | +| Fork discovery, preview, and creation | Source workspace admin | +| Sync preview/apply and mapping read/update | Admin on both workspaces on a direct fork edge | +| Rollback / unlink | Target admin / acting-side admin, respectively | +| Selector discovery | Read access in the discovery workspace and access to the selected credential | +| Operation get/list | Read access in the receipt workspace | + +Workspace API keys can import where existing authoring policy allows, but cannot bind credentials, administer forks, or execute selectors. Do not substitute a key owner for an acting user. OAuth import/fork/sync previews require `api:write`, even though preview does not commit changes. Export, fork discovery, mapping reads, selectors, and operation reads use `api:read`. + +## Portable imports + +Default exports remain sanitized. `includeReferences=true` adds a versioned manifest of registered resource IDs and source occurrences, including nested tools. It does not include secret values. Imported provenance and source IDs are labels; they never grant access to an alleged source workspace. + +Set `BASE_URL` to your deployment, and use an authorized key for each workspace. Raw HTTP returns `{ "data": ... }`; the CLI unwraps single-resource results. Extract the export payload before saving it: + +```sh +curl -sS --fail-with-body \ + -H "X-API-Key: $SOURCE_API_KEY" \ + "$BASE_URL/api/v2/workflows/$WORKFLOW_ID/export?includeReferences=true" \ + | jq '.data' > workflow.json +``` + +Save destination mappings as `import-mappings.json`, replacing the example IDs with manifest and destination resource IDs: + +```json +[ + { "kind": "credential", "sourceId": "source-connection", "targetId": "destination-connection" }, + { "kind": "sandbox", "sourceId": "source-sandbox", "targetId": "destination-sandbox" } +] +``` + +`mappings` applies to every occurrence of a resource's `kind` and `sourceId`. For older exports, `bindings` can address individual registered occurrences. Its entries are flat objects, without `sourceId` or an `occurrence` wrapper: + +```json +[ + { + "kind": "credential", + "blockId": "source-agent", + "subBlockKey": "tools", + "valuePath": [0, "params", "oauthCredential"], + "encoding": "scalar", + "targetId": "destination-connection" + } +] +``` + +A top-level field uses `valuePath: []`. Other registered encodings are `array`, `csv`, `files`, and `environment`; multi-value occurrences can include `positions`. Use the actual registered occurrence, rather than inventing paths. Conflicting instructions for one occurrence are rejected. `targetId: null` requests clearing; it cannot satisfy a required binding. Destination type, provider, and parent-child compatibility are validated. + +Build one request file and preview it: + +```sh +jq -n --arg workspaceId "$DESTINATION_WORKSPACE" \ + --slurpfile workflow workflow.json --slurpfile mappings import-mappings.json \ + '{workspaceId: $workspaceId, workflow: $workflow[0], mappings: $mappings[0]}' \ + > import-request.json + +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' --data @import-request.json \ + "$BASE_URL/api/v2/workflows/import/preview" > import-preview.json +``` + +Inspect `data.unresolvedBindings`, `data.configuration`, `data.unresolvedConfiguration`, and `data.discovery`. Dependent choices use a different shape from bindings: + +```json +[ + { "blockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" } +] +``` + +Add these as `dependentValues` to `import-request.json` and preview again. For a field with `multiSelect: true`, `value` is a comma-separated string of selected IDs. For credential-backed selectors, discover options in the import's destination workspace using the returned `selectorKey` and `context`: + +```sh +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "$(jq -n --arg workspaceId "$DESTINATION_WORKSPACE" \ + '{workspaceId: $workspaceId, selectorKey: "gmail.labels", context: {oauthCredential: "destination-connection"}, limit: 50}')" \ + "$BASE_URL/api/v2/selectors/list" +``` + +Selector lists return `{ "data": [...], "nextCursor": null, "truncated": false }`; check both pagination and truncation. Detail uses `/selectors/get` with the same scope/context and an `id`. MCP tools use `mcp.tools` with `mcpServerId`. A missing OAuth connection can require human authorization; changing a resource ID cannot create that connection. + +When `data.ready` is true, save a stable request ID and apply the exact reviewed choices: + +```sh +jq --arg requestId "$IMPORT_REQUEST_ID" \ + --arg fingerprint "$(jq -er '.data.previewFingerprint' import-preview.json)" \ + '. + {requestId: $requestId, previewFingerprint: $fingerprint}' \ + import-request.json > import-apply.json + +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' --data @import-apply.json \ + "$BASE_URL/api/v2/workflows/import" > import-result.json +``` + +Mapped import creates a draft, its graph, variables, required inline custom tools, and receipt atomically. Remapping precedes graph ID regeneration; the result includes `idMap`. Plain imports without mapping options retain their earlier behavior and return no operation receipt. Supplying mapping options, even empty arrays, requires `requestId` and `previewFingerprint`. + +## Fork and sync + +Fork preview and apply share `{ "name": "Review environment", "copy": { "tables": ["source-table"] } }`. Apply adds `requestId` and the preview's fingerprint. Eligible deployed source workflows become child drafts; resource copies must be selected explicitly. + +Push sends deployed workflows from the current workspace to `otherWorkspaceId`. Pull sends them from `otherWorkspaceId` to the current workspace. Either endpoint works from either side of the direct parent/child edge. + +For example, save this as `sync-request.json` for a pull into the workspace in the URL: + +```json +{ + "otherWorkspaceId": "source-workspace", + "mappings": [ + { "resourceType": "oauth_credential", "sourceId": "source-connection", "targetId": "destination-connection" } + ], + "dependentValues": [ + { "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" } + ], + "copyResources": { "tables": ["source-table"] } +} +``` + +Sync mappings use edge `resourceType` names, such as `oauth_credential`, `service_account_credential`, `knowledge_base`, `custom_tool`, or `sandbox`; imports use `kind`, such as `credential`, `knowledge-base`, or `custom-tool`. Sync workflow identity is system-managed. Pick a knowledge document through its parent KB's dependent selector instead of writing a `knowledge_document` edge mapping. + +Mapping inspection requires `otherWorkspaceId` and `direction` in its query. Read rows also contain a storage `id`; write requests accept only `resourceType`, `sourceId`, and `targetId`. Project a page with `jq '[.data[] | {resourceType, sourceId, targetId}]'` before reusing its mappings, and follow `nextCursor` to collect further pages. + +Preview never saves inline mappings. Apply persists them with the sync transaction. Dependent overrides use source workflow/block/field identities, including original nested tool indices. Omission reuses saved choices; providing `dependentValues` replaces those choices for affected workflows, and `[]` clears them. Target draft values alone are not saved sync configuration. + +```sh +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + -H 'Content-Type: application/json' --data @sync-request.json \ + "$BASE_URL/api/v2/workspaces/$CURRENT_WORKSPACE/fork/pull/preview" > sync-preview.json + +jq --arg requestId "$SYNC_REQUEST_ID" \ + --arg fingerprint "$(jq -er '.data.previewFingerprint' sync-preview.json)" \ + '. + {requestId: $requestId, previewFingerprint: $fingerprint, confirm: true}' \ + sync-request.json > sync-apply.json + +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + -H 'Content-Type: application/json' --data @sync-apply.json \ + "$BASE_URL/api/v2/workspaces/$CURRENT_WORKSPACE/fork/pull" > sync-result.json +``` + +Review `unresolvedBindings`, `configuration`, planned workflow actions, and retiring trigger URLs before apply. Use each configuration field's `discoveryWorkspaceId` for `/selectors/list` or `/selectors/get`: source when its parent will be copied, destination when mapped. Pass its returned context and re-preview any changed choices. A ready preview is not a deployment-readiness report. + +`triggerSlots` lists stable `sourceWorkflowId` and `sourceBlockId` identities, `ownPath`, `adoptablePaths`, and `defaultAdoptPath`. A slot with `ownPath` preserves that URL and accepts no override. For an arriving trigger, `triggerMappings` can select an offered retiring path or `null` to request a new URL. Include the same choices on preview and apply: + +```json +[ + { "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-trigger", "adoptPath": "retiring-trigger-path" } +] +``` + +Unknown source identities, duplicate choices, and paths outside that slot's candidates are rejected. Adoption candidates stay within the same target workflow and provider; do not construct them from a different workflow's URL. + +Copy selections use `copy` for fork creation and `copyResources` for sync. Fork `copy.files` contains workspace file IDs; sync `copyResources.files` contains storage keys. Credentials and secret values are not copied. `dropReferences` only acknowledges references deleted in the source; it cannot discard live source resources. + +Sync replaces eligible target workflows and schedules deployment of the admitted snapshots. Exclusions remain in effect. A deleted source can archive its mapped target; an undeployed source does not. Rollback restores the latest target sync's prior deployed versions, with no promise to restore arbitrary drafts or undo all resource copies. + +## Receipts, polling, and retries + +Apply returns a single-resource envelope. This is an example completed sync report: + +```json +{ + "data": { + "operationId": "operation-id", + "requestId": "release-2026-09-09", + "workspaceId": "current-workspace", + "kind": "workspace_pull", + "applied": true, + "status": "completed", + "resourceIds": ["destination-workflow"], + "issues": [], + "deployments": [ + { + "operationId": "deployment-id", + "workflowId": "destination-workflow", + "version": 2, + "status": "active", + "ready": true, + "pendingComponents": [] + } + ] + } +} +``` + +Always poll under the returned `workspaceId`. Import receipts belong to the destination. Fork-creation receipts belong to the source on which `/fork` ran. Push/pull receipts belong to the current workspace in the request URL, including a push that changes the other workspace. + +```sh +OPERATION_ID=$(jq -er '.data.operationId' sync-result.json) +OPERATION_WORKSPACE=$(jq -er '.data.workspaceId' sync-result.json) + +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + "$BASE_URL/api/v2/workspaces/$OPERATION_WORKSPACE/operations/$OPERATION_ID" +``` + +Poll with a delay while `status` is `processing`. Terminal outcomes are `completed`, `completed_with_warnings`, `requires_configuration`, and `failed`. Inspect `issues`, `copyProgress`, `deployments[].ready`, `pendingComponents`, and `triggerUrlChanges`. `applied: true` remains true after a follow-up failure: the transaction committed. Completed imports and forks remain drafts; a completed sync requires checking its admitted deployment results before treating the destination as ready. + +Request IDs deduplicate within the receipt workspace. Retain the complete apply request: identical authorized retries return the original operation before checking preview freshness. A changed payload under the same ID, a stale preview, or blocked apply returns HTTP `409` with `{ "error": { "code": "CONFLICT", "message": "...", "details": {} } }`. Other invalid inputs can return `400`; follow-up failures are reported on a committed operation. + +After an uncertain response, retry the identical request with the original ID, or query `GET /workspaces/{workspaceId}/operations?requestId=...`. Lists return `{ "data": [...], "nextCursor": ... }`; use operation get for refreshed completion status. If a stale preview is refused before commit, obtain a new preview and use a new request ID for the revised request. Never replace a lost-response request with a fresh ID merely to retry. + +## What the test harness covers + +Run `bun run test:workflow-sync` from the Sim repository with Bun, dependencies, and Docker available. It creates disposable PostgreSQL 17, exercises actual authorization, API-key authentication, v2 HTTP adapters, CLI subprocesses, graph/receipt transactions, locks, and deployment outbox workers, then removes the database. Tests cover concurrent retries, stale previews, atomic refusal, immutable deployment snapshots, exclusions, pagination, and copy-worker recovery. + +External provider options and failures use controlled fixtures; the separate realtime process uses an authenticated loopback fixture. This validates the platform protocol rather than proving every provider account is connected. Verify provider authorization and destination deployment readiness in the environment you intend to use. Migration safety is checked separately from this fresh-schema harness. diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index ac51f72acda..d5454bfcd66 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -45,6 +45,7 @@ These apply to every command, and may be written before or after it. | [`sim meta`](/cli/meta) | Manage meta | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | +| [`sim selectors`](/cli/selectors) | Manage selectors | | [`sim skills`](/cli/skills) | Manage skills | | [`sim tables`](/cli/tables) | Manage tables | | [`sim tools`](/cli/tools) | Manage tools | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 3b9bb713014..9a0b7bfd3b4 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -8,6 +8,7 @@ "configuration", "output", "scripting", + "workflow-sync", "troubleshooting", "---Commands---", "commands", @@ -26,6 +27,7 @@ "meta", "sandboxes", "secrets", + "selectors", "skills", "tables", "tools", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 19cdae57d7f..22c00ff4e27 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2992,6 +2992,50 @@ sim secrets set [options] +## sim selectors + +### sim selectors get + +Get Selector Option (OAuth login or personal API key required) + +```bash +sim selectors get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--id ` | Yes | Resource identifier. | + + + +### sim selectors list + +List Selector Options (OAuth login or personal API key required) + +```bash +sim selectors list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--search ` | No | Provider option search text. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + ## sim skills Also spelled `sim skill`. @@ -5430,7 +5474,7 @@ sim workflows run [options] Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export [options] ``` **Arguments** @@ -5443,6 +5487,16 @@ sim workflows export +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-references` | No | Include non-secret resource identities for mapped import. | + + + ### sim workflows get Get Workflow @@ -5656,6 +5710,13 @@ sim workflows import [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | No | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | @@ -5703,6 +5764,30 @@ sim workflows move [options] +### sim workflows import-preview + +Preview Workflow Import + +```bash +sim workflows import-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--name ` | No | Override for the imported workflow name. | +| `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | + + + ### sim workflows restore Restore an archived workflow @@ -5907,6 +5992,29 @@ sim workflows mkdir Also spelled `sim workspace`. +### sim workspaces fork + +Fork Workspace (OAuth login or personal API key required) + +```bash +sim workspaces fork [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | + + + ### sim workspaces get Get Workspace @@ -5915,6 +6023,174 @@ Get Workspace sim workspaces get ``` +### sim workspaces fork-availability + +Get Workspace Fork Availability (OAuth login or personal API key required) + +```bash +sim workspaces fork-availability +``` + +### sim workspaces lineage + +Get Workspace Fork Lineage (OAuth login or personal API key required) + +```bash +sim workspaces lineage +``` + +### sim workspaces mappings get + +Get Workspace Fork Mappings (OAuth login or personal API key required) + +```bash +sim workspaces mappings get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + +### sim workspaces mappings update + +Update Workspace Fork Mappings (OAuth login or personal API key required) + +```bash +sim workspaces mappings update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--mappings ` | Yes | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces operations get + +Get Workspace Operation + +```bash +sim workspaces operations get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Durable operation identifier to use for polling. | + + + +### sim workspaces operations list + +List Workspace Operations + +```bash +sim workspaces operations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | + + + +### sim workspaces operations wait + +Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout + +```bash +sim workspaces operations wait [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Operation ID returned by import, fork, push, or pull | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--wait-timeout ` | No | Maximum total wait (default 3600; 0 waits indefinitely). | + + + +### sim workspaces children + +List Workspace Fork Children (OAuth login or personal API key required) + +```bash +sim workspaces children [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `desc`. | + + + +### sim workspaces fork-resources + +List Workspace Fork Resources (OAuth login or personal API key required) + +```bash +sim workspaces fork-resources [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--kind ` | Yes | Resource or operation kind. Accepted values: `files`, `tables`, `knowledgeBases`, `customTools`, `skills`, `mcpServers`, `workflowMcpServers`. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + ### sim workspaces members List workspace members @@ -5952,3 +6228,181 @@ sim workspaces list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + +### sim workspaces fork-preview + +Preview Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces fork-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces pull-preview + +Preview Workspace Pull (OAuth login or personal API key required) + +```bash +sim workspaces pull-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces push-preview + +Preview Workspace Push (OAuth login or personal API key required) + +```bash +sim workspaces push-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces pull + +Pull Workspace (OAuth login or personal API key required) + +```bash +sim workspaces pull [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces push + +Push Workspace (OAuth login or personal API key required) + +```bash +sim workspaces push [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces fork-rollback + +Rollback Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces fork-rollback [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces unlink + +Unlink Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces unlink [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces sync-exclusions + +Update Workspace Fork Exclusions (OAuth login or personal API key required) + +```bash +sim workspaces sync-exclusions [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow identifiers in the current workspace. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--fork-sync-excluded ` | Yes | Whether the named workflows should be skipped as sync sources and targets. Accepted values: `true`, `false`. | + + diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx new file mode 100644 index 00000000000..4a9dda8556d --- /dev/null +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -0,0 +1,50 @@ +--- +title: Selectors +description: Manage selectors — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get selector option + +```bash +sim selectors get [options] +``` + +Get Selector Option (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--id ` | Yes | Resource identifier. | + + + +## List selector options + +```bash +sim selectors list [options] +``` + +List Selector Options (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--search ` | No | Provider option search text. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/cli/workflow-sync.mdx b/apps/docs/content/docs/cli/workflow-sync.mdx new file mode 100644 index 00000000000..076494631f3 --- /dev/null +++ b/apps/docs/content/docs/cli/workflow-sync.mdx @@ -0,0 +1,162 @@ +--- +title: Workflow imports and workspace sync +description: Preview resource bindings, apply reviewed changes, and verify readiness from an LLM or CI client +--- + +Use an explicit profile and workspace for every environment. Fork administration requires a personal API key or OAuth login; workspace API keys cannot administer fork edges. Fork creation requires source admin access. Sync and mapping changes require admin access on both workspaces. + +These commands use the same v2 operations as a direct API client. See the [API workflow guide](/api-reference/workflow-sync) for HTTP requests, response envelopes, and the command-to-endpoint mapping. CLI JSON output unwraps single-resource responses, so read `.previewFingerprint`; raw HTTP clients read `.data.previewFingerprint`. + +## Import a workflow with destination bindings + +Portable export is opt-in. It adds a versioned reference manifest to the sanitized graph; credentials and secret values stay in their original workspace. + +```sh +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workflows export "$WORKFLOW_ID" --include-references > workflow.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import-preview --workflow @workflow.json > preview.json +``` + +Read `unresolvedBindings` and `unresolvedConfiguration`. Resource mappings select a destination by resource kind and source ID. For example, `mappings.json` can contain: + +```json +[ + { "kind": "credential", "sourceId": "source-connection", "targetId": "destination-connection" }, + { "kind": "sandbox", "sourceId": "source-sandbox", "targetId": "destination-sandbox" } +] +``` + +Use the existing credentials, tables, files, sandboxes, and other resource commands to discover or create destination resources. An older export without reference metadata can use `--bindings @bindings.json` to address individual source fields. For example, a nested Agent tool's credential binding is: + +```json +[ + { + "kind": "credential", + "blockId": "source-agent", + "subBlockKey": "tools", + "valuePath": [0, "params", "oauthCredential"], + "encoding": "scalar", + "targetId": "destination-connection" + } +] +``` + +Use the field path and encoding registered for the actual export. A top-level field uses its own `subBlockKey` and `valuePath: []`. Field bindings have no `sourceId`; they address an occurrence directly. Conflicting resource mappings and field bindings are rejected. Include `--bindings` on both preview and apply when using them. + +Dependent choices use the selector key and destination context returned by preview. For example: + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + selectors list --selector-key gmail.labels \ + --context '{"oauthCredential":"destination-connection"}' +``` + +MCP tool discovery uses `mcp.tools` with `mcpServerId`. OAuth connections may require a human to authorize the provider before discovery can succeed. + +Import dependent values use source block IDs and field keys: + +```json +[{ "blockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" }] +``` + +Preview again with the exact choices, then apply with that fingerprint and a request ID saved by your client: + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import-preview --workflow @workflow.json \ + --mappings @mappings.json --dependent-values @values.json > preview.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import --workflow @workflow.json \ + --mappings @mappings.json --dependent-values @values.json \ + --preview-fingerprint "$(jq -r .previewFingerprint preview.json)" \ + --request-id "$REQUEST_ID" --wait +``` + +Mapped import creates a draft atomically after required bindings and configuration are resolved. The receipt includes the imported IDs and `idMap`. Imports without mapping options retain the existing behavior; they do not return a durable operation receipt. Supplying mapping options, even an empty `--mappings '[]'`, requires both `--request-id` and `--preview-fingerprint`. JSON flags accept `@file` and `@-` for stdin. + +## Fork, push, and pull + +Inspect `workspaces fork-availability`, `lineage`, and `fork-resources`, then use `fork-preview` and `fork` with identical name and copy selections. A fork creates child drafts; resource copying must be explicitly selected. + +```sh +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork-resources --kind tables --limit 50 + +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork-preview --name "Review environment" \ + --copy '{"tables":["source-table"]}' > fork-preview.json + +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork --name "Review environment" \ + --copy '{"tables":["source-table"]}' \ + --preview-fingerprint "$(jq -r .previewFingerprint fork-preview.json)" \ + --request-id "$FORK_REQUEST_ID" --wait +``` + +Push means the current workspace sends its deployed workflows to `--other-workspace-id`. Pull means the other workspace sends them to the current workspace. These meanings are the same on either side of the edge. + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workspaces pull-preview --other-workspace-id "$SOURCE_WORKSPACE" > sync-preview.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workspaces pull --other-workspace-id "$SOURCE_WORKSPACE" \ + --preview-fingerprint "$(jq -r .previewFingerprint sync-preview.json)" \ + --request-id "$SYNC_REQUEST_ID" --yes --wait +``` + +Include the same inline mappings and dependent values on preview and apply. Sync values identify `sourceWorkflowId`, `sourceBlockId`, and `subBlockKey`; preview target IDs are not stable public override identities. Sync mapping entries use `resourceType`, `sourceId`, and `targetId` as returned by mapping inspection; import entries use `kind`. Inline sync mappings persist on the fork edge in the same transaction as the sync. + +`workspaces mappings get` also returns a storage `id` on each row. Strip it before reusing a result as input: `jq '[.data[] | {resourceType, sourceId, targetId}]'`. Use `oauth_credential` or `service_account_credential` for sync credential mappings, according to the credential type. + +For example, `--mappings` and `--dependent-values` take these respective arrays: + +```json +[{ "resourceType": "oauth_credential", "sourceId": "source-connection", "targetId": "destination-connection" }] +``` + +```json +[{ "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" }] +``` + +Use `--copy-resources` for sync copies, such as `'{"tables":["source-table"]}'`; fork creation uses `--copy`. Fork `copy.files` takes workspace file IDs; sync `copyResources.files` takes storage keys. Neither copies credentials or secret values. + +For each sync configuration field, run selector discovery in its `discoveryWorkspaceId` with the returned context. This is the source workspace when the parent resource is being copied, and the destination workspace for an existing mapping. Use the returned field key unchanged, including nested tool indices. When `multiSelect` is true, send selected IDs as one comma-separated string. + +Saved sync choices apply when `--dependent-values` is omitted. Supplying the flag replaces saved choices for the affected workflows; an explicit `[]` clears them. Values that exist only in a target draft are not saved sync choices. + +Sync preview's `ready` describes whether the change can commit; it does not report deployment readiness. Sync replaces eligible target workflows and deploys the admitted snapshots. It preserves exclusions. Deleting a source can archive its mapped target; merely undeploying it does not. Rollback restores the latest target sync using prior deployed versions, and does not restore arbitrary drafts or undo every resource copy. + +Inspect preview's `triggerSlots` before replacing webhook triggers. Slots with `ownPath` keep it. For other slots, `--trigger-mappings` accepts entries with `sourceWorkflowId`, `sourceBlockId`, and `adoptPath` selected from `adoptablePaths`, or `null` for a new URL. Pass the identical choices to preview and apply; unknown, duplicate, and unavailable choices are rejected. + +## Reconcile completion and retries + +A successful apply returns `operationId`, `requestId`, `applied`, `status`, resource IDs, and structured issues. `applied: true` means the transaction committed, even if later copying or deployment fails. + +Poll in the receipt's `workspaceId`, which may differ from the workspace receiving workflows: + +| Operation | Receipt workspace | +| --- | --- | +| Import | Destination workspace | +| Fork creation | Source workspace on which `fork` ran | +| Push or pull | Current `--workspace` on which the command ran | + +```sh +sim --profile automation --workspace "$OPERATION_WORKSPACE_ID" --output json \ + workspaces operations wait "$OPERATION_ID" --wait-timeout 300 +``` + +If the response was lost, use `workspaces operations list --request-id "$REQUEST_ID"` in that same workspace, or retry the identical mutation. List results retain `{ "data": [...], "nextCursor": ... }`; single operation and preview results are unwrapped. Operation lists contain stored snapshots; use `operations get` or `operations wait` to refresh completion status. + +Verify deployment readiness before treating a synced environment as ready. Inspect trigger URL changes and configuration issues in the report. Completion with warnings exits successfully; required configuration exits 3, failed completion exits 1, and wait timeout exits 4. Timeout and uncertain-mutation diagnostics retain reconciliation IDs. + +After a lost response, retry the exact mutation with the original request ID. Identical retries return the same operation. Changing inputs under that ID returns 409. If a preview is stale and nothing committed, request a new preview and use a new request ID for the revised request. The CLI never invents a fresh ID to retry an uncertain mutation. + +## Validation boundary + +The repository's `bun run test:workflow-sync` harness starts a disposable PostgreSQL database and exercises real authorization, v2 HTTP adapters, CLI subprocesses, transactions, receipts, and deployment workers. External provider responses and the separate realtime process use controlled fixtures. This covers platform behavior and failure recovery; a provider connection still needs validation in the destination environment. + +Completed imports and forks are drafts. For sync, check the report's deployment readiness and copied-resource progress before using the environment. diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index b1340cc251c..7dbfc39efc2 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -552,7 +552,7 @@ sim workflows run [options] ## Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export [options] ``` **Arguments** @@ -565,6 +565,16 @@ sim workflows export +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-references` | No | Include non-secret resource identities for mapped import. | + + + ## Get workflow ```bash @@ -764,6 +774,13 @@ sim workflows import [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | No | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | @@ -807,6 +824,28 @@ sim workflows move [options] +## Preview workflow import + +```bash +sim workflows import-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--name ` | No | Override for the imported workflow name. | +| `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | + + + ## Restore an archived workflow ```bash diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index d92c8eb3d26..b64bdbc3e72 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -9,12 +9,197 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Fork workspace + +```bash +sim workspaces fork [options] +``` + +Fork Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | + + + ## Get workspace ```bash sim workspaces get ``` +## Get workspace fork availability + +```bash +sim workspaces fork-availability +``` + +Get Workspace Fork Availability (OAuth login or personal API key required) + +## Get workspace fork lineage + +```bash +sim workspaces lineage +``` + +Get Workspace Fork Lineage (OAuth login or personal API key required) + +## Get workspace fork mappings + +```bash +sim workspaces mappings get [options] +``` + +Get Workspace Fork Mappings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + +## Update workspace fork mappings + +```bash +sim workspaces mappings update [options] +``` + +Update Workspace Fork Mappings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--mappings ` | Yes | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | + + + +## Get workspace operation + +```bash +sim workspaces operations get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Durable operation identifier to use for polling. | + + + +## List workspace operations + +```bash +sim workspaces operations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | + + + +## Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout + +```bash +sim workspaces operations wait [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Operation ID returned by import, fork, push, or pull | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--wait-timeout ` | No | Maximum total wait (default 3600; 0 waits indefinitely). | + + + +## List workspace fork children + +```bash +sim workspaces children [options] +``` + +List Workspace Fork Children (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `desc`. | + + + +## List workspace fork resources + +```bash +sim workspaces fork-resources [options] +``` + +List Workspace Fork Resources (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--kind ` | Yes | Resource or operation kind. Accepted values: `files`, `tables`, `knowledgeBases`, `customTools`, `skills`, `mcpServers`, `workflowMcpServers`. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + ## List workspace members ```bash @@ -48,3 +233,181 @@ sim workspaces list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + +## Preview workspace fork + +```bash +sim workspaces fork-preview [options] +``` + +Preview Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | + + + +## Preview workspace pull + +```bash +sim workspaces pull-preview [options] +``` + +Preview Workspace Pull (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +## Preview workspace push + +```bash +sim workspaces push-preview [options] +``` + +Preview Workspace Push (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +## Pull workspace + +```bash +sim workspaces pull [options] +``` + +Pull Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Push workspace + +```bash +sim workspaces push [options] +``` + +Push Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Rollback workspace fork + +```bash +sim workspaces fork-rollback [options] +``` + +Rollback Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Unlink workspace fork + +```bash +sim workspaces unlink [options] +``` + +Unlink Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Update workspace fork exclusions + +```bash +sim workspaces sync-exclusions [options] +``` + +Update Workspace Fork Exclusions (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow identifiers in the current workspace. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--fork-sync-excluded ` | Yes | Whether the named workflows should be skipped as sync sources and targets. Accepted values: `true`, `false`. | + + diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 2bdfb47bf3c..7a2bcdb26f9 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,8 +33,9 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(133) + expect(Object.keys(paths)).toHaveLength(152) expect(tags.map((tag) => tag.name)).toEqual([ + 'Workspace Sync', 'Workflows', 'Workflow Runs', 'Logs', diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 2a79bb7be6b..c1fe939dd8f 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Workspace Sync", + "description": "Portable workflow configuration, workspace forks, push and pull, and durable operation status." + }, { "name": "Workflows", "description": "Manage and execute workflow definitions, folders, deployment versions, and portable imports and exports." @@ -1994,7 +1998,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. 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`.", + "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`.", "x-sim-operation": "workflows.export", "x-oauth-scope": "api:read", "tags": ["Workflows"], @@ -2010,6 +2014,16 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } + }, + { + "name": "includeReferences", + "in": "query", + "required": false, + "description": "Include non-secret resource identifiers and source field occurrences for mapped imports.", + "schema": { + "description": "Include non-secret resource identifiers and source field occurrences for mapped imports.", + "type": "boolean" + } } ], "responses": { @@ -2065,17 +2079,17 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "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`.", "x-sim-operation": "workflows.import", "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, - "description": "Portable workflow data and destination metadata for an import.", + "description": "Workflow document, destination, and optional reviewed mappings.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportWorkflowRequest" + "$ref": "#/components/schemas/ImportWorkflowBody" } } } @@ -3638,3797 +3652,8074 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "headers": { - "Content-Type": { - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", - "schema": { - "type": "string", - "title": "Content type", - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." - } - }, - "Content-Disposition": { - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", - "schema": { - "type": "string", - "title": "Content disposition", - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." - } - }, - "Content-Length": { - "description": "File size in bytes.", - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)$", - "title": "Content length", - "description": "File size in bytes." - } - }, - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } - } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { + "/api/v2/workspaces/{workspaceId}/fork/preview": { + "post": { + "operationId": "previewWorkspaceFork", + "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`.", + "x-sim-operation": "workspaces.fork.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "USAGE_LIMIT_EXCEEDED", - "message": "Usage limit exceeded. Please upgrade your plan to continue." + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspaceForkBody" } } } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspaceForkResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork": { + "post": { + "operationId": "forkWorkspace", + "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`.", + "x-sim-operation": "workspaces.fork.create", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Webhook path already in use" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForkWorkspaceBody" } } } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Run ID has already been used", - "details": { - "code": "RUN_ID_CONFLICT", - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForkWorkspaceResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork/push/preview": { + "post": { + "operationId": "previewWorkspacePush", + "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`.", + "x-sim-operation": "workspaces.fork.sync.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "Locked": { - "description": "The resource is temporarily locked or unavailable; retry the request.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "Workflow is locked" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePushBody" } } } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePushResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork/push": { + "post": { + "operationId": "pushWorkspace", + "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`.", + "x-sim-operation": "workspaces.fork.sync", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CLIENT_CLOSED_REQUEST", - "message": "Client cancelled request", - "details": { - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" - } - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushWorkspaceBody" } } } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushWorkspaceResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } } }, - "schemas": { - "V2ActionableForbiddenDetails": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" + "/api/v2/workspaces/{workspaceId}/fork/pull/preview": { + "post": { + "operationId": "previewWorkspacePull", + "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`.", + "x-sim-operation": "workspaces.fork.sync.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePullBody" } - }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + } } }, - "required": ["error"], - "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePullResponse" + } + } } - } - ] - }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" - }, - "WorkflowListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the workflow." + "409": { + "$ref": "#/components/responses/Conflict" }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "500": { + "$ref": "#/components/responses/InternalError" }, - "lastRunAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/pull": { + "post": { + "operationId": "pullWorkspace", + "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`.", + "x-sim-operation": "workspaces.fork.sync", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullWorkspaceBody" } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + } } }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Workflow summary", - "description": "Summary of a workflow and its deployment and run state." - }, - "WorkflowListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowListItem" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullWorkspaceResponse" + } } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Workflow list response", - "description": "A cursor-paginated page of workflow summaries.", - "examples": [ + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/availability": { + "get": { + "operationId": "getWorkspaceForkAvailability", + "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`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": [ - { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - ], - "nextCursor": null + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } } - ] - }, - "SeededWorkflowBlock": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Block identifier." + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkAvailabilityResponse" + } + } + } }, - "type": { - "type": "string", - "description": "Registered block type." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "name": { - "type": "string", - "description": "Block display name." - } - }, - "required": ["id", "type", "name"], - "additionalProperties": false, - "title": "Seeded workflow block", - "description": "A block the platform placed in a newly created workflow." - }, - "CreateWorkflowResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "409": { + "$ref": "#/components/responses/Conflict" }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the workflow." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "500": { + "$ref": "#/components/responses/InternalError" }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/lineage": { + "get": { + "operationId": "getWorkspaceForkLineage", + "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`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkLineageResponse" + } } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + } }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastRunAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "404": { + "$ref": "#/components/responses/NotFound" }, - "blocks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeededWorkflowBlock" - }, - "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt", - "blocks" - ], - "additionalProperties": false, - "title": "Create workflow result", - "description": "The created workflow and the blocks it was seeded with." - }, - "CreateWorkflowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/CreateWorkflowResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create workflow response", - "description": "The created workflow and the blocks it was seeded with.", - "examples": [ + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/children": { + "get": { + "operationId": "listWorkspaceForkChildren", + "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`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z", - "blocks": [ - { - "id": "start-1", - "type": "starter", - "name": "Start" - } - ] + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } - } - ] - }, - "CreateWorkflowRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the workflow." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Workflow name." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "description": { - "description": "Optional workflow description.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId", "name"], - "additionalProperties": false, - "title": "Create workflow request", - "description": "Name, description, workspace, and optional folder for a new workflow." - }, - "WorkflowBlock": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Block identifier, unique within the workflow." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "createdAt", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["createdAt"] + } }, - "type": { - "type": "string", - "description": "Registered block type." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["desc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceForkChildrenResponse" + } + } + } }, - "name": { - "type": "string", - "description": "Block display name; must be unique within the workflow." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "position": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "Canvas x coordinate." - }, - "y": { - "type": "number", - "description": "Canvas y coordinate." - } - }, - "required": ["x", "y"], - "additionalProperties": false, - "description": "Canvas coordinates of a block." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "subBlocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Sub-block identifier." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Sub-block input type." - }, - "value": { - "description": "Configured value; shape depends on the sub-block type." - } - }, - "required": ["id", "type", "value"], - "additionalProperties": false, - "description": "One configurable input on a block." - }, - "description": "Configured inputs keyed by sub-block id." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Declared shape of one output; depends on the block type." - }, - "description": "Declared output shape keyed by output name." + "404": { + "$ref": "#/components/responses/NotFound" }, - "enabled": { - "type": "boolean", - "description": "Whether the block runs." + "409": { + "$ref": "#/components/responses/Conflict" }, - "horizontalHandles": { - "description": "Whether edge handles render horizontally.", - "type": "boolean" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "height": { - "description": "Rendered block height.", - "type": "number" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "advancedMode": { - "description": "Whether the block is edited in advanced mode.", - "type": "boolean" + "500": { + "$ref": "#/components/responses/InternalError" }, - "errorEnabled": { - "description": "Whether the block exposes an error branch.", - "type": "boolean" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/resources": { + "get": { + "operationId": "listWorkspaceForkResources", + "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`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } }, - "retry": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether the block retries on failure." - }, - "maxTries": { - "type": "integer", - "minimum": 2, - "maximum": 5, - "description": "Total attempts, including the first." - }, - "waitBetweenTriesMs": { - "type": "integer", - "minimum": 0, - "maximum": 5000, - "description": "Delay between attempts, in milliseconds." - } - }, - "required": ["enabled", "maxTries", "waitBetweenTriesMs"], - "additionalProperties": false, - "description": "Per-block retry configuration." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "triggerMode": { - "description": "Whether the block acts as the workflow trigger.", - "type": "boolean" + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "data": { - "type": "object", - "properties": { - "parentId": { - "description": "Identifier of the containing loop or parallel.", - "type": "string" - }, - "extent": { - "description": "Constrains the block to its parent bounds.", - "type": "string", - "const": "parent" - }, - "width": { - "description": "Rendered container width.", - "type": "number" - }, - "height": { - "description": "Rendered container height.", - "type": "number" - }, - "collection": { - "description": "Items a forEach loop or collection parallel iterates." - }, - "count": { - "description": "Iteration count for a `for` loop or count parallel.", - "type": "number" - }, - "loopType": { - "description": "Loop container kind.", - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" - }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" - }, - "parallelType": { - "description": "Parallel container kind.", - "type": "string", - "enum": ["collection", "count"] - }, - "batchSize": { - "description": "Maximum concurrent branches of a parallel.", - "type": "number" - }, - "type": { - "description": "Container subtype.", - "type": "string" - }, - "canonicalModes": { - "description": "Per-field editing mode, keyed by canonical parameter id.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["basic", "advanced"] + { + "name": "kind", + "in": "query", + "required": true, + "description": "Resource or operation kind.", + "schema": { + "type": "string", + "enum": [ + "files", + "tables", + "knowledgeBases", + "customTools", + "skills", + "mcpServers", + "workflowMcpServers" + ], + "description": "Resource or operation kind." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "id", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["id"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceForkResourcesResponse" } } - }, - "additionalProperties": false, - "description": "Container and layout metadata carried by a block." + } }, - "locked": { - "description": "Whether the block is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], - "additionalProperties": false, - "title": "Workflow block", - "description": "One node of a workflow graph and its configuration." - }, - "WorkflowEdge": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Edge identifier, unique within the workflow." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "source": { - "type": "string", - "minLength": 1, - "description": "Source block id." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "target": { - "type": "string", - "minLength": 1, - "description": "Target block id." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "sourceHandle": { - "description": "Source port, or null for the block default.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "targetHandle": { - "description": "Target port, or null for the block default.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "409": { + "$ref": "#/components/responses/Conflict" }, - "type": { - "description": "Edge renderer type.", - "type": "string" - } - }, - "required": ["id", "source", "target"], - "additionalProperties": false, - "title": "Workflow edge", - "description": "A directed connection between two blocks." - }, - "WorkflowLoop": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Loop container identifier; equal to the loop block id." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the loop." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "iterations": { - "type": "number", - "description": "Resolved iteration count." + "500": { + "$ref": "#/components/responses/InternalError" }, - "loopType": { - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"], - "description": "Loop kind." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/mappings": { + "get": { + "operationId": "getWorkspaceForkMappings", + "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`.", + "x-sim-operation": "workspaces.fork.mappings.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } }, - "forEachItems": { - "description": "Items a forEach loop iterates, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item the loop iterates." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item the loop iterates." - } - }, - { - "type": "string" - } - ] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" + { + "name": "otherWorkspaceId", + "in": "query", + "required": true, + "description": "Workspace on the other side of the direct fork edge.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" + { + "name": "direction", + "in": "query", + "required": true, + "description": "Push means current to other; pull means other to current, independent of parent/child orientation.", + "schema": { + "type": "string", + "enum": ["push", "pull"], + "description": "Push means current to other; pull means other to current, independent of parent/child orientation." + } }, - "enabled": { - "description": "Whether the loop runs.", - "type": "boolean" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "locked": { - "description": "Whether the loop is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "nodes", "iterations", "loopType"], - "additionalProperties": false, - "title": "Workflow loop", - "description": "A loop container derived from the workflow blocks." - }, - "WorkflowParallel": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Parallel container identifier; equal to the parallel block id." + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the parallel." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "id", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["id"] + } }, - "distribution": { - "description": "Items distributed across branches, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item distributed to a branch." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item distributed to a branch." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkMappingsResponse" } - }, - { - "type": "string" } - ] + } }, - "count": { - "description": "Fixed branch count.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "parallelType": { - "description": "Parallel kind.", - "type": "string", - "enum": ["count", "collection"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "batchSize": { - "description": "Maximum concurrent branches.", - "type": "number" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "enabled": { - "description": "Whether the parallel runs.", - "type": "boolean" + "404": { + "$ref": "#/components/responses/NotFound" }, - "locked": { - "description": "Whether the parallel is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "nodes"], - "additionalProperties": false, - "title": "Workflow parallel", - "description": "A parallel container derived from the workflow blocks." - }, - "WorkflowVariable": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Variable identifier." + "409": { + "$ref": "#/components/responses/Conflict" }, - "name": { - "type": "string", - "description": "Variable name, referenced from block inputs." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "type": { - "default": "string", - "description": "Declared variable type.", - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "value": { - "description": "Variable value; free-form and validated per `type` at use time." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["id", "name", "type", "value"], - "additionalProperties": false, - "title": "Workflow variable", - "description": "A workflow-scoped variable." + } }, - "WorkflowGraph": { - "type": "object", - "properties": { - "blocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowBlock" - }, - "description": "Blocks keyed by block id." + "put": { + "operationId": "updateWorkspaceForkMappings", + "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`.", + "x-sim-operation": "workspaces.fork.mappings.update", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsResponse" + } + } + } }, - "edges": { - "maxItems": 10000, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEdge" - }, - "description": "Directed connections between blocks." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "loops": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowLoop" - }, - "description": "Loop containers keyed by container id; always present, `{}` when there are none." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "parallels": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowParallel" - }, - "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "variables": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowVariable" - }, - "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["blocks", "edges", "loops", "parallels", "variables"], - "additionalProperties": false, - "title": "Workflow graph", - "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." - }, - "WorkflowStateResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowGraph" + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/rollback": { + "post": { + "operationId": "rollbackWorkspaceFork", + "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`.", + "x-sim-operation": "workspaces.fork.rollback", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollbackWorkspaceForkBody" + } + } } }, - "required": ["data"], - "additionalProperties": false, - "title": "Workflow state response", - "description": "The editable draft graph of a workflow.", - "examples": [ + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollbackWorkspaceForkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/unlink": { + "post": { + "operationId": "unlinkWorkspaceFork", + "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`.", + "x-sim-operation": "workspaces.fork.unlink", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": { - "blocks": {}, - "edges": [], - "loops": {}, - "parallels": {}, - "variables": {} + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - ] - }, - "WorkflowLintReport": { - "type": "object", - "properties": { - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkWorkspaceForkBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkWorkspaceForkResponse" } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable." + } + } }, - "sinks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with no outgoing edge." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "orphanBlocks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with neither an incoming nor an outgoing edge." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "emptyOutgoingPorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "handle": { - "type": "string", - "description": "Source handle with nothing connected to it." - }, - "label": { - "type": "string", - "description": "Human-readable name of the port." - } - }, - "required": ["blockId", "blockName", "blockType", "handle", "label"], - "additionalProperties": false - }, - "description": "Branch and container ports that lead nowhere." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "invalidBranchPorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "sourceHandle": { - "type": "string", - "description": "Source handle that does not match the block." - }, - "reason": { - "type": "string", - "description": "Why the handle is not valid for this block." + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/exclusions": { + "put": { + "operationId": "updateWorkspaceForkExclusions", + "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`.", + "x-sim-operation": "workspaces.fork.exclusions", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsResponse" } - }, - "required": ["blockId", "blockName", "blockType", "sourceHandle", "reason"], - "additionalProperties": false - }, - "description": "Condition and router edges whose source handle names no real branch." + } + } }, - "invalidConnectionTargets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sourceBlockId": { - "type": "string", - "description": "Block the edge leaves." - }, - "sourceBlockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the source block." - }, - "sourceHandle": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Handle the edge leaves from." - }, - "targetBlockId": { - "type": "string", - "description": "Block the edge points at." - }, - "reason": { - "type": "string", - "description": "Why the target is not a legal destination." + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/import/preview": { + "post": { + "operationId": "previewWorkflowImport", + "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`.", + "x-sim-operation": "workflows.import.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "Portable workflow data and destination metadata for an import.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkflowImportResponse" } - }, - "required": [ - "sourceBlockId", - "sourceBlockName", - "sourceHandle", - "targetBlockId", - "reason" - ], - "additionalProperties": false - }, - "description": "Edges pointing at a block that cannot legally receive them." + } + } }, - "fieldIssues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "missingRequiredFields": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Required sub-block fields that resolve empty in the active mode." - }, - "inactiveModeValues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "canonicalId": { - "type": "string", - "description": "Canonical parameter the two sub-block modes share." - }, - "activeMemberId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sub-block the runtime reads, where the value should live." - }, - "inactiveMemberId": { - "type": "string", - "description": "Sub-block holding the stranded value, which the runtime ignores." - }, - "kind": { - "type": "string", - "enum": ["credential", "resource", "other"], - "description": "What kind of value is stranded." - } - }, - "required": ["canonicalId", "activeMemberId", "inactiveMemberId", "kind"], - "additionalProperties": false - }, - "description": "Values stranded on the inactive member of a canonical pair." + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/selectors/list": { + "post": { + "operationId": "listSelector", + "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`.", + "x-sim-operation": "selectors.execute", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSelectorBody" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSelectorResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/selectors/get": { + "post": { + "operationId": "getSelector", + "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`.", + "x-sim-operation": "selectors.execute", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSelectorBody" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSelectorResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/operations/{operationId}": { + "get": { + "operationId": "getWorkspaceOperation", + "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`.", + "x-sim-operation": "workspaces.operations.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + }, + { + "name": "operationId", + "in": "path", + "required": true, + "description": "Durable operation identifier to use for polling.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + } + } + ], + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceOperationResponse" } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/operations": { + "get": { + "operationId": "listWorkspaceOperations", + "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`.", + "x-sim-operation": "workspaces.operations.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "requestId", + "in": "query", + "required": false, + "description": "Stable client request ID for reconciliation and identical retries.", + "schema": { + "description": "Stable client request ID for reconciliation and identical retries.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + ], + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceOperationsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } + } + } + } + }, + "RunIdConflict": { + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "ClientClosedRequest": { + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "WorkflowListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow summary", + "description": "Summary of a workflow and its deployment and run state." + }, + "WorkflowListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow list response", + "description": "A cursor-paginated page of workflow summaries.", + "examples": [ + { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SeededWorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name." + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false, + "title": "Seeded workflow block", + "description": "A block the platform placed in a newly created workflow." + }, + "CreateWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeededWorkflowBlock" + }, + "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "blocks" + ], + "additionalProperties": false, + "title": "Create workflow result", + "description": "The created workflow and the blocks it was seeded with." + }, + "CreateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/CreateWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow response", + "description": "The created workflow and the blocks it was seeded with.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "blocks": [ + { + "id": "start-1", + "type": "starter", + "name": "Start" + } + ] + } + } + ] + }, + "CreateWorkflowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the workflow." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name." + }, + "description": { + "description": "Optional workflow description.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow request", + "description": "Name, description, workspace, and optional folder for a new workflow." + }, + "WorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "additionalProperties": false, + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "additionalProperties": false, + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "additionalProperties": false, + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "additionalProperties": false, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "additionalProperties": false, + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdge": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "additionalProperties": false, + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoop": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "additionalProperties": false, + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "additionalProperties": false, + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariable": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "description": "Variable name, referenced from block inputs." + }, + "type": { + "default": "string", + "description": "Declared variable type.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "additionalProperties": false, + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "WorkflowGraph": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlock" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdge" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoop" + }, + "description": "Loop containers keyed by container id; always present, `{}` when there are none." + }, + "parallels": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallel" + }, + "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariable" + }, + "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + } + }, + "required": ["blocks", "edges", "loops", "parallels", "variables"], + "additionalProperties": false, + "title": "Workflow graph", + "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." + }, + "WorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowGraph" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow state response", + "description": "The editable draft graph of a workflow.", + "examples": [ + { + "data": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {}, + "variables": {} + } + } + ] + }, + "WorkflowLintReport": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable." + }, + "sinks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no outgoing edge." + }, + "orphanBlocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with neither an incoming nor an outgoing edge." + }, + "emptyOutgoingPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "handle": { + "type": "string", + "description": "Source handle with nothing connected to it." + }, + "label": { + "type": "string", + "description": "Human-readable name of the port." + } + }, + "required": ["blockId", "blockName", "blockType", "handle", "label"], + "additionalProperties": false + }, + "description": "Branch and container ports that lead nowhere." + }, + "invalidBranchPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "sourceHandle": { + "type": "string", + "description": "Source handle that does not match the block." + }, + "reason": { + "type": "string", + "description": "Why the handle is not valid for this block." + } + }, + "required": ["blockId", "blockName", "blockType", "sourceHandle", "reason"], + "additionalProperties": false + }, + "description": "Condition and router edges whose source handle names no real branch." + }, + "invalidConnectionTargets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceBlockId": { + "type": "string", + "description": "Block the edge leaves." + }, + "sourceBlockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the source block." + }, + "sourceHandle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Handle the edge leaves from." + }, + "targetBlockId": { + "type": "string", + "description": "Block the edge points at." + }, + "reason": { + "type": "string", + "description": "Why the target is not a legal destination." + } + }, + "required": [ + "sourceBlockId", + "sourceBlockName", + "sourceHandle", + "targetBlockId", + "reason" + ], + "additionalProperties": false + }, + "description": "Edges pointing at a block that cannot legally receive them." + }, + "fieldIssues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "missingRequiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required sub-block fields that resolve empty in the active mode." + }, + "inactiveModeValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "canonicalId": { + "type": "string", + "description": "Canonical parameter the two sub-block modes share." + }, + "activeMemberId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Sub-block the runtime reads, where the value should live." + }, + "inactiveMemberId": { + "type": "string", + "description": "Sub-block holding the stranded value, which the runtime ignores." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "other"], + "description": "What kind of value is stranded." + } + }, + "required": ["canonicalId", "activeMemberId", "inactiveMemberId", "kind"], + "additionalProperties": false + }, + "description": "Values stranded on the inactive member of a canonical pair." + } + }, + "required": [ + "blockId", + "blockName", + "blockType", + "missingRequiredFields", + "inactiveModeValues" + ], + "additionalProperties": false + }, + "description": "Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time." + }, + "unresolvedReferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "field": { + "type": "string", + "description": "Sub-block field holding the reference." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The reference, or references, that did not resolve." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], + "description": "What kind of entity the reference was expected to name." + }, + "reason": { + "type": "string", + "description": "Why the reference does not resolve." + } + }, + "required": ["blockId", "blockName", "blockType", "field", "value", "kind", "reason"], + "additionalProperties": false + }, + "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." + }, + "notes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory notes about the report itself." + } + }, + "required": [ + "sources", + "sinks", + "orphanBlocks", + "emptyOutgoingPorts", + "invalidBranchPorts", + "invalidConnectionTargets", + "fieldIssues", + "unresolvedReferences", + "notes" + ], + "additionalProperties": false, + "title": "Workflow lint report", + "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + }, + "ReplaceWorkflowStateResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce." + } + }, + "required": ["id", "warnings", "needsRedeployment", "lint", "dryRun"], + "additionalProperties": false, + "title": "Replace workflow state result", + "description": "Outcome of replacing a workflow draft graph, with its advisory findings." + }, + "ReplaceWorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow state response", + "description": "Outcome of replacing a workflow draft graph.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "warnings": [], + "needsRedeployment": true, + "dryRun": false, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [], + "unresolvedReferences": [], + "notes": [] + } + } + } + ] + }, + "WorkflowBlockInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdgeInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoopInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallelInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariableInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name, referenced from block inputs." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "ReplaceWorkflowStateRequest": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlockInput" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdgeInput" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "description": "Ignored on write: loop containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoopInput" + } + }, + "parallels": { + "description": "Ignored on write: parallel containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallelInput" + } + }, + "variables": { + "description": "Replacement variable set. Omit to leave the stored variables untouched.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariableInput" + } + } + }, + "required": ["blocks", "edges"], + "additionalProperties": false, + "title": "Replace workflow state request", + "description": "A complete replacement draft graph for a workflow.", + "examples": [ + { + "blocks": {}, + "edges": [] + } + ] + }, + "WorkflowSkippedItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "block_not_found", + "invalid_block_type", + "block_not_allowed", + "model_not_allowed", + "block_locked", + "tool_not_allowed", + "invalid_edge_target", + "invalid_edge_source", + "invalid_edge_scope", + "invalid_source_handle", + "invalid_target_handle", + "invalid_subblock_field", + "missing_required_params", + "invalid_subflow_parent", + "nested_subflow_not_allowed", + "duplicate_block_name", + "reserved_block_name", + "retry_not_supported", + "duplicate_trigger", + "duplicate_single_instance_block", + "disabled_ancestor" + ], + "description": "Machine-readable reason the engine declined an operation." + }, + "operationType": { + "type": "string", + "description": "The `operation_type` that was declined." + }, + "blockId": { + "type": "string", + "description": "Block the declined operation targeted." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation." + }, + "details": { + "description": "Additional context for the reason; keys depend on `type`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One piece of engine-supplied context for the reason." + } + } + }, + "required": ["type", "operationType", "blockId", "reason"], + "additionalProperties": false, + "title": "Workflow skipped item", + "description": "One operation the edit engine did not apply." + }, + "WorkflowInputValidationError": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block whose input was rejected." + }, + "blockType": { + "type": "string", + "description": "Type of the block whose input was rejected." + }, + "field": { + "type": "string", + "description": "Sub-block field that was rejected." + }, + "error": { + "type": "string", + "description": "Why the value was rejected." + } + }, + "required": ["blockId", "blockType", "field", "error"], + "additionalProperties": false, + "title": "Workflow input validation error", + "description": "One block input that was dropped rather than persisted." + }, + "ApplyWorkflowOperationsResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Operations the engine applied." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Operations the engine declined. Empty when everything applied." + }, + "deferred": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Edges waiting for target blocks. They apply automatically when their targets exist, in this batch or a later one. Do not resubmit them." + }, + "inputValidationErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputValidationError" + }, + "description": "Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead." + }, + "mintedBlockIds": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "The id the block was actually given." + }, + "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce." + } + }, + "required": [ + "id", + "warnings", + "needsRedeployment", + "applied", + "skipped", + "deferred", + "inputValidationErrors", + "mintedBlockIds", + "lint", + "dryRun" + ], + "additionalProperties": false, + "title": "Apply workflow operations result", + "description": "Outcome of a batch of semantic edits against a workflow graph." + }, + "ApplyWorkflowOperationsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow operations response", + "description": "Outcome of a batch of semantic edits.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "applied": 1, + "skipped": [], + "deferred": [], + "inputValidationErrors": [], + "mintedBlockIds": { + "triage": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" + }, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [ + { + "blockId": "agent-1", + "blockName": "Triage", + "blockType": "agent", + "missingRequiredFields": ["systemPrompt"], + "inactiveModeValues": [] + } + ], + "unresolvedReferences": [], + "notes": [] + }, + "warnings": [], + "needsRedeployment": true, + "dryRun": false + } + } + ] + }, + "WorkflowEditOperation": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "add", + "description": "Create a new block." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + }, + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "required": ["type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "edit", + "description": "Change an existing block: its inputs, name, or connections." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "allOf": [ + { + "type": "object", + "properties": { + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "additionalProperties": { + "description": "One operation parameter; see the description for the accepted keys." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One operation parameter; see the description for the accepted keys." + } + } + ], + "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "delete", + "description": "Remove a block and every edge touching it." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + } + }, + "required": ["operation_type", "block_id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "insert_into_subflow", + "description": "Create a block inside a loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container to insert the block into." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + }, + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "required": ["subflowId", "type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "extract_from_subflow", + "description": "Move a block out of its loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container the block moves into or out of." + } + }, + "required": ["subflowId"], + "additionalProperties": { + "description": "One block-specific input." + }, + "description": "Container identifier, plus any block-specific inputs." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + } + ], + "title": "Workflow edit operation", + "description": "One semantic edit against a workflow graph." + }, + "AgentToolInput": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentTool" + }, + "description": "The complete value stored in an Agent block’s `tools` input.", + "title": "Agent tools input" + }, + "AgentTool": { + "oneOf": [ + { + "$ref": "#/components/schemas/AgentIntegrationTool" + }, + { + "$ref": "#/components/schemas/AgentCustomTool" + }, + { + "$ref": "#/components/schemas/AgentMcpTool" + }, + { + "$ref": "#/components/schemas/AgentMcpServerAdvanced" + } + ], + "title": "Agent tool", + "description": "A catalog integration operation, workspace custom tool, or MCP tool available to an Agent." + }, + "AgentIntegrationTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$", + "description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id." + }, + "operation": { + "description": "Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One tool parameter value." + }, + "description": "Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them." + } + }, + "required": ["type"], + "additionalProperties": { + "description": "Forward-compatible integration tool metadata preserved by the workflow editor." + }, + "title": "Agent integration tool", + "description": "A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.", + "examples": [ + { + "type": "cloudwatch", + "operation": "describe_alarm_history", + "usageControl": "auto", + "params": {} + } + ] + }, + "AgentCustomTool": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "custom-tool", + "description": "Custom-tool discriminator." + }, + "customToolId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Custom tool ID from List Custom Tools." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "customToolId"], + "additionalProperties": { + "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + } + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "custom-tool", + "description": "Custom-tool discriminator." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "description": "Function declaration discriminator.", + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Function name presented to the model." + }, + "description": { + "description": "What the inline custom tool does.", + "type": "string" + }, + "parameters": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One JSON Schema keyword on the function parameters." + }, + "description": "JSON Schema describing the function arguments." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Additional function declaration metadata." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["function"], + "additionalProperties": { + "description": "Additional custom tool declaration metadata." + }, + "description": "Inline OpenAI-style function declaration." + }, + "code": { + "type": "string", + "description": "Inline tool implementation executed by the Function runtime." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "schema", "code"], + "additionalProperties": { + "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + } + } + ], + "title": "Agent custom tool", + "description": "A workspace custom tool. Prefer `customToolId`; inline declarations are also accepted.", + "examples": [ + { + "type": "custom-tool", + "customToolId": "cst_01J9X2ABCDEF", + "usageControl": "auto" + } + ] + }, + "AgentMcpTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp", + "description": "MCP-tool discriminator." + }, + "params": { + "allOf": [ + { + "type": "object", + "properties": { + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "MCP server id returned by `GET /api/v2/mcp-servers`." + }, + "toolName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Tool name returned by the MCP server’s tools endpoint." + } + }, + "required": ["serverId", "toolName"], + "additionalProperties": { + "description": "One parameter fixed by the workflow author." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One parameter fixed by the workflow author." + } + } + ], + "description": "MCP server and tool identity plus any tool arguments fixed by the workflow author." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "params"], + "additionalProperties": { + "description": "Forward-compatible MCP tool metadata preserved by the workflow editor." + }, + "title": "Agent MCP tool", + "description": "One tool discovered from a workspace MCP server.", + "examples": [ + { + "type": "mcp", + "params": { + "serverId": "mcp_01J9X2ABCDEF", + "toolName": "search_docs" + }, + "usageControl": "auto" + } + ] + }, + "AgentMcpServerAdvanced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp-server-advanced", + "description": "Server-wide MCP binding discriminator." + }, + "params": { + "type": "object", + "properties": { + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID." + } + }, + "required": ["serverId"], + "additionalProperties": false, + "description": "Server identity for discovering and invoking every available MCP tool." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "params"], + "additionalProperties": { + "description": "Forward-compatible MCP server metadata preserved by the workflow editor." + }, + "title": "Agent MCP server (advanced)", + "description": "All tools available to the executing subject from one MCP server.", + "examples": [ + { + "type": "mcp-server-advanced", + "params": { + "serverId": "mcp_01J9X2ABCDEF" + }, + "usageControl": "auto" + } + ] + }, + "ApplyWorkflowOperationsRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEditOperation" + }, + "description": "Edits to apply, in a single batch." + }, + "atomic": { + "default": false, + "description": "Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.", + "type": "boolean" + }, + "layout": { + "default": "targeted", + "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", + "type": "string", + "enum": ["targeted", "none"] + }, + "setBlockEnabled": { + "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block should run." + } + }, + "required": ["block_id", "enabled"], + "additionalProperties": false + } + } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow operations request", + "description": "A batch of semantic edits against a workflow graph.", + "examples": [ + { + "operations": [ + { + "operation_type": "add", + "block_id": "agent-1", + "params": { + "type": "agent", + "name": "Triage", + "inputs": { + "tools": [ + { + "type": "cloudwatch", + "operation": "describe_alarm_history", + "usageControl": "auto", + "params": {} + } + ] + } + } + } + ] + } + ] + }, + "ApplyWorkflowVariablesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose variables were updated." + }, + "variableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Variables the workflow now holds." + }, + "changed": { + "type": "boolean", + "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." + } + }, + "required": ["id", "variableCount", "changed"], + "additionalProperties": false, + "title": "Apply workflow variables result", + "description": "Outcome of a workflow variable update." + }, + "ApplyWorkflowVariablesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow variables response", + "description": "Outcome of a workflow variable update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "variableCount": 3, + "changed": true + } + } + ] + }, + "ApplyWorkflowVariablesRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "add", + "description": "Create a variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value, coerced to `type`." + } + }, + "required": ["operation", "name", "type", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "edit", + "description": "Replace the value, and optionally the type, of an existing variable." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to update." + }, + "type": { + "description": "Replacement type; the stored type is kept when omitted.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Replacement value, coerced to the effective type." + } + }, + "required": ["operation", "name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "delete", + "description": "Remove the variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to remove." + } + }, + "required": ["operation", "name"], + "additionalProperties": false + } + ], + "description": "One variable change." + }, + "description": "Variable changes to apply, in order." + } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow variables request", + "description": "Additions, edits, and deletions against a workflow’s variables." + }, + "DuplicateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Duplicate workflow response", + "description": "The created copy.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage (copy)", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "DuplicateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "folderPath": { + "description": "Destination folder path. Defaults to the source workflow's folder.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Duplicate workflow request", + "description": "Optional name and destination folder for the copy." + }, + "RestoreWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore workflow response", + "description": "The restored workflow.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "MoveWorkflowsResult": { + "type": "object", + "properties": { + "moved": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were relocated." + }, + "failed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical destination folder path.", + "maxLength": 4096 + } + }, + "required": ["moved", "failed", "folderPath"], + "additionalProperties": false, + "title": "Move workflows result", + "description": "Which workflows moved and which did not." + }, + "MoveWorkflowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/MoveWorkflowsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Move workflows response", + "description": "Which workflows moved and which did not.", + "examples": [ + { + "data": { + "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], + "failed": [], + "folderPath": "/Operations" + } + } + ] + }, + "MoveWorkflowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace holding every workflow in the batch." + }, + "workflowIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Workflows to move. Duplicates are collapsed." + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflows to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "workflowIds", "folderPath"], + "additionalProperties": false, + "title": "Move workflows request", + "description": "Workflows to relocate and the folder to relocate them into." + }, + "WorkflowInputField": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Input field name." + }, + "type": { + "type": "string", + "description": "Input field type." + }, + "description": { + "description": "Optional input field description.", + "type": "string" + } + }, + "required": ["name", "type"], + "additionalProperties": false, + "title": "Workflow input field", + "description": "A deployed API trigger input exposed by a workflow." + }, + "WorkflowDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "blockId", - "blockName", - "blockType", - "missingRequiredFields", - "inactiveModeValues" - ], - "additionalProperties": false + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" }, - "description": "Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time." + "additionalProperties": { + "description": "Structured workflow variable value." + }, + "description": "Workflow-scoped variables keyed by variable identifier." }, - "unresolvedReferences": { + "inputs": { "type": "array", "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "field": { - "type": "string", - "description": "Sub-block field holding the reference." - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "The reference, or references, that did not resolve." - }, - "kind": { - "type": "string", - "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], - "description": "What kind of entity the reference was expected to name." - }, - "reason": { - "type": "string", - "description": "Why the reference does not resolve." - } + "$ref": "#/components/schemas/WorkflowInputField" + }, + "description": "Input fields exposed by the workflow API trigger." + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "variables", + "inputs" + ], + "additionalProperties": false, + "title": "Workflow detail", + "description": "Full workflow summary with variables and API-trigger input fields." + }, + "WorkflowDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow detail response", + "description": "Detailed workflow metadata, variables, and trigger inputs.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "variables": {}, + "inputs": [] + } + } + ] + }, + "UpdateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow response", + "description": "The updated workflow summary.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "UpdateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Replacement workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Replacement workflow description; null clears it.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflow to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Update workflow request", + "description": "Fields to update on an existing workflow." + }, + "DeleteWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the archived workflow." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the workflow is no longer live." + }, + "archived": { + "type": "boolean", + "const": true, + "description": "Whether the workflow was archived. Restore Workflow recovers it and the schedules, webhooks, MCP tools, and chats archived with it." + } + }, + "required": ["id", "deleted", "archived"], + "additionalProperties": false, + "title": "Delete workflow result", + "description": "Confirmation that a workflow was archived." + }, + "DeleteWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow response", + "description": "Confirmation that the workflow was archived.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true, + "archived": true + } + } + ] + }, + "WorkflowVersion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deployment-version identifier." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "description": "Optional deployment-version label.", + "anyOf": [ + { + "type": "string" }, - "required": ["blockId", "blockName", "blockType", "field", "value", "kind", "reason"], - "additionalProperties": false - }, - "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." + { + "type": "null" + } + ] }, - "notes": { + "description": { + "description": "Optional deployment-version release note.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is currently serving executions." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version was created.", + "format": "date-time" + }, + "deployedBy": { + "description": "Display name of the user who created the deployment, when available.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestOperationStatus": { + "description": "Latest lifecycle-operation status for this version.", + "anyOf": [ + { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "version", "isActive", "createdAt"], + "additionalProperties": false, + "title": "Workflow version", + "description": "A saved deployment version of a workflow." + }, + "WorkflowVersionListResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/WorkflowVersion" }, - "description": "Advisory notes about the report itself." + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": [ - "sources", - "sinks", - "orphanBlocks", - "emptyOutgoingPorts", - "invalidBranchPorts", - "invalidConnectionTargets", - "fieldIssues", - "unresolvedReferences", - "notes" - ], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Workflow lint report", - "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + "title": "Workflow version list response", + "description": "A cursor-paginated page of deployment versions.", + "examples": [ + { + "data": [ + { + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Jane Smith", + "latestOperationStatus": "active" + } + ], + "nextCursor": null + } + ] }, - "ReplaceWorkflowStateResult": { + "DeployedWorkflowState": { + "title": "Deployed workflow state", + "description": "Workflow graph snapshot pinned by a deployment version.", + "type": "object", + "additionalProperties": true + }, + "WorkflowVersionDetail": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the workflow whose draft graph was written." + "description": "Unique deployment-version identifier." }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." }, - "needsRedeployment": { - "type": "boolean", - "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version label, or null when unset." }, - "lint": { - "$ref": "#/components/schemas/WorkflowLintReport" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version release note, or null when unset." }, - "dryRun": { + "isActive": { "type": "boolean", - "description": "Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce." + "description": "Whether this version is currently serving executions." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version was created.", + "format": "date-time" + }, + "state": { + "description": "Workflow graph saved with this deployment version. Sensitive values are redacted to null.", + "$ref": "#/components/schemas/DeployedWorkflowState" } }, - "required": ["id", "warnings", "needsRedeployment", "lint", "dryRun"], + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], "additionalProperties": false, - "title": "Replace workflow state result", - "description": "Outcome of replacing a workflow draft graph, with its advisory findings." + "title": "Workflow version detail", + "description": "A deployment version together with the workflow state it pins." + }, + "WorkflowVersionDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowVersionDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow version detail response", + "description": "The deployment version and its pinned workflow graph.", + "examples": [ + { + "data": { + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [] + } + } + } + ] + }, + "WorkflowVersionMetadata": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version label, or null when unset." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version release note, or null when unset." + } + }, + "required": ["version", "name", "description"], + "additionalProperties": false, + "title": "Workflow version metadata", + "description": "Mutable label and release note of a deployment version." }, - "ReplaceWorkflowStateResponse": { + "UpdateWorkflowVersionResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + "$ref": "#/components/schemas/WorkflowVersionMetadata" } }, "required": ["data"], "additionalProperties": false, - "title": "Replace workflow state response", - "description": "Outcome of replacing a workflow draft graph.", + "title": "Update workflow version response", + "description": "The deployment version metadata after the update.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "warnings": [], - "needsRedeployment": true, - "dryRun": false, - "lint": { - "sources": [], - "sinks": [], - "orphanBlocks": [], - "emptyOutgoingPorts": [], - "invalidBranchPorts": [], - "invalidConnectionTargets": [], - "fieldIssues": [], - "unresolvedReferences": [], - "notes": [] - } + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch." } } ] }, - "WorkflowBlockInput": { + "UpdateWorkflowVersionRequest": { "type": "object", "properties": { - "id": { + "name": { + "description": "New label for the deployment version.", "type": "string", "minLength": 1, - "description": "Block identifier, unique within the workflow." + "maxLength": 100 }, - "type": { + "description": { + "description": "New release note for the deployment version, or null to clear it.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "title": "Update workflow version request", + "description": "Merge-patch body for the mutable metadata of a deployment version.", + "examples": [ + { + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + ] + }, + "ActiveDeploymentSummary": { + "type": "object", + "properties": { + "deploymentVersionId": { "type": "string", - "minLength": 1, - "description": "Registered block type." + "description": "Identifier of the active deployment version." }, - "name": { + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Numeric active deployment version." + }, + "deployedAt": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Block display name; must be unique within the workflow." + "description": "ISO 8601 timestamp when this version became active.", + "format": "date-time" + } + }, + "required": ["deploymentVersionId", "version", "deployedAt"], + "additionalProperties": false, + "title": "Active deployment", + "description": "Summary of the workflow version currently serving API executions." + }, + "DeploymentOperationSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deployment operation identifier." }, - "position": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "Canvas x coordinate." + "deploymentVersionId": { + "type": "string", + "description": "Deployment version targeted by this operation." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Numeric deployment version." + }, + "action": { + "type": "string", + "enum": ["deploy", "activate"], + "description": "Operation being performed on the deployment version." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current deployment lifecycle status." + }, + "isCurrent": { + "default": true, + "description": "Whether this operation still describes the current deployment attempt.", + "type": "boolean" + }, + "readiness": { + "$ref": "#/components/schemas/DeploymentReadiness" + }, + "requestedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment operation was requested.", + "format": "date-time" + }, + "activatedAt": { + "description": "ISO 8601 activation timestamp, or null before activation completes.", + "format": "date-time", + "anyOf": [ + { + "type": "string" }, - "y": { - "type": "number", - "description": "Canvas y coordinate." + { + "type": "null" } - }, - "required": ["x", "y"], - "description": "Canvas coordinates of a block." + ] }, - "subBlocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Sub-block identifier." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Sub-block input type." - }, - "value": { - "description": "Configured value; shape depends on the sub-block type." - } + "error": { + "description": "Deployment failure details, or null when no failure occurred.", + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationError" }, - "required": ["id", "type", "value"], - "description": "One configurable input on a block." - }, - "description": "Configured inputs keyed by sub-block id." + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "deploymentVersionId", + "version", + "action", + "status", + "isCurrent", + "readiness", + "requestedAt" + ], + "additionalProperties": false, + "title": "Deployment operation", + "description": "Lifecycle state of a deployment or version-activation attempt." + }, + "DeploymentReadiness": { + "type": "object", + "properties": { + "webhooks": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "Webhook synchronization readiness." }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Declared shape of one output; depends on the block type." - }, - "description": "Declared output shape keyed by output name." + "schedules": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "Schedule synchronization readiness." + }, + "mcp": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "MCP synchronization readiness." + } + }, + "required": ["webhooks", "schedules", "mcp"], + "additionalProperties": false, + "title": "Deployment readiness", + "description": "Readiness of the side effects required to activate a deployment." + }, + "DeploymentOperationError": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable deployment failure code." + }, + "message": { + "type": "string", + "description": "Human-readable deployment failure message." + }, + "retryable": { + "type": "boolean", + "description": "Whether retrying the deployment may succeed." + } + }, + "required": ["code", "message", "retryable"], + "additionalProperties": false, + "title": "Deployment operation error", + "description": "Failure details for a deployment lifecycle operation." + }, + "VersionActivationResult": { + "title": "Version activation result", + "description": "Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.", + "$ref": "#/components/schemas/RollbackResult" + }, + "RollbackResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "enabled": { + "isDeployed": { "type": "boolean", - "description": "Whether the block runs." - }, - "horizontalHandles": { - "description": "Whether edge handles render horizontally.", - "type": "boolean" - }, - "height": { - "description": "Rendered block height.", - "type": "number" + "description": "Whether a workflow version is currently live and available for API execution." }, - "advancedMode": { - "description": "Whether the block is edited in advanced mode.", - "type": "boolean" + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "errorEnabled": { - "description": "Whether the block exposes an error branch.", - "type": "boolean" + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "retry": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether the block retries on failure." - }, - "maxTries": { - "type": "integer", - "minimum": 2, - "maximum": 5, - "description": "Total attempts, including the first." + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, - "waitBetweenTriesMs": { - "type": "integer", - "minimum": 0, - "maximum": 5000, - "description": "Delay between attempts, in milliseconds." + { + "type": "null" } - }, - "required": ["enabled", "maxTries", "waitBetweenTriesMs"], - "description": "Per-block retry configuration." + ], + "description": "Currently live deployment version, or null while no version is active." }, - "triggerMode": { - "description": "Whether the block acts as the workflow trigger.", - "type": "boolean" + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], + "additionalProperties": false, + "title": "Rollback result", + "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." + }, + "ActivateWorkflowVersionResponse": { + "type": "object", + "properties": { "data": { "type": "object", "properties": { - "parentId": { - "description": "Identifier of the containing loop or parallel.", - "type": "string" - }, - "extent": { - "description": "Constrains the block to its parent bounds.", - "type": "string", - "const": "parent" - }, - "width": { - "description": "Rendered container width.", - "type": "number" - }, - "height": { - "description": "Rendered container height.", - "type": "number" - }, - "collection": { - "description": "Items a forEach loop or collection parallel iterates." - }, - "count": { - "description": "Iteration count for a `for` loop or count parallel.", - "type": "number" - }, - "loopType": { - "description": "Loop container kind.", + "id": { "type": "string", - "enum": ["for", "forEach", "while", "doWhile"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "parallelType": { - "description": "Parallel container kind.", - "type": "string", - "enum": ["collection", "count"] + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "batchSize": { - "description": "Maximum concurrent branches of a parallel.", - "type": "number" + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." }, - "type": { - "description": "Container subtype.", - "type": "string" + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, - "canonicalModes": { - "description": "Per-field editing mode, keyed by canonical parameter id.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["basic", "advanced"] - } + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." } }, - "description": "Container and layout metadata carried by a block." - }, - "locked": { - "description": "Whether the block is locked against edits.", - "type": "boolean" + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/VersionActivationResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Activate workflow version response", + "description": "Current deployment state after accepting the activation attempt.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", + "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", + "version": 3, + "action": "activate", + "status": "activating", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null + }, + "version": 3 + } } - }, - "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], - "title": "Workflow block", - "description": "One node of a workflow graph and its configuration." + ] }, - "WorkflowEdgeInput": { + "ActivateWorkflowVersionRequest": { + "default": {}, + "title": "Activate workflow version request", + "description": "No body. The version to promote is named by the request path.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "RevertWorkflowVersionResult": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Edge identifier, unique within the workflow." - }, - "source": { - "type": "string", - "minLength": 1, - "description": "Source block id." - }, - "target": { - "type": "string", - "minLength": 1, - "description": "Target block id." - }, - "sourceHandle": { - "description": "Source port, or null for the block default.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "description": "Unique workflow identifier." }, - "targetHandle": { - "description": "Target port, or null for the block default.", + "version": { "anyOf": [ { - "type": "string" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, { - "type": "null" + "type": "string", + "const": "active" } - ] + ], + "description": "Deployment version loaded into the draft, or `active` for the live version." }, - "type": { - "description": "Edge renderer type.", - "type": "string" + "lastSaved": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Epoch milliseconds at which the overwritten draft was saved." } }, - "required": ["id", "source", "target"], - "title": "Workflow edge", - "description": "A directed connection between two blocks." + "required": ["id", "version", "lastSaved"], + "additionalProperties": false, + "title": "Revert workflow version result", + "description": "The draft after it was overwritten by a deployment version." }, - "WorkflowLoopInput": { + "RevertWorkflowVersionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RevertWorkflowVersionResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revert workflow version response", + "description": "The draft after it was overwritten by the deployment version.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "version": 3, + "lastSaved": 1765535400000 + } + } + ] + }, + "RevertWorkflowVersionRequest": { + "default": {}, + "title": "Revert workflow version request", + "description": "No body. The version to load into the draft is named by the request path.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "WorkflowDeployment": { "type": "object", "properties": { "id": { "type": "string", - "description": "Loop container identifier; equal to the loop block id." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "nodes": { + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] + }, + "warnings": { "type": "array", "items": { "type": "string" }, - "description": "Block ids inside the loop." - }, - "iterations": { - "type": "number", - "description": "Resolved iteration count." - }, - "loopType": { - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"], - "description": "Loop kind." + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "forEachItems": { - "description": "Items a forEach loop iterates, or the expression producing them.", + "activeDeployment": { "anyOf": [ { - "type": "array", - "items": { - "description": "One item the loop iterates." - } + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item the loop iterates." - } + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." + }, + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" }, { - "type": "string" + "type": "null" } - ] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" - }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, - "enabled": { - "description": "Whether the loop runs.", - "type": "boolean" + "needsRedeployment": { + "type": "boolean", + "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." }, - "locked": { - "description": "Whether the loop is locked against edits.", - "type": "boolean" + "isPublicApi": { + "type": "boolean", + "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." } }, - "required": ["id", "nodes", "iterations", "loopType"], - "title": "Workflow loop", - "description": "A loop container derived from the workflow blocks." + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "needsRedeployment", + "isPublicApi" + ], + "additionalProperties": false, + "title": "Workflow deployment", + "description": "Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt." }, - "WorkflowParallelInput": { + "WorkflowDeploymentResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Parallel container identifier; equal to the parallel block id." - }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the parallel." - }, - "distribution": { - "description": "Items distributed across branches, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item distributed to a branch." - } + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow deployment response", + "description": "Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "needsRedeployment": true, + "isPublicApi": false, + "deployedAt": "2026-06-12T10:30:00.000Z", + "warnings": [], + "activeDeployment": { + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "deployedAt": "2026-06-12T10:30:00.000Z" }, - { - "type": "object", - "propertyNames": { - "type": "string" + "latestDeploymentAttempt": { + "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "action": "deploy", + "status": "active", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" }, - "additionalProperties": { - "description": "One item distributed to a branch." - } - }, - { - "type": "string" + "requestedAt": "2026-06-12T10:29:58.000Z", + "activatedAt": "2026-06-12T10:30:00.000Z", + "error": null } - ] - }, - "count": { - "description": "Fixed branch count.", - "type": "number" - }, - "parallelType": { - "description": "Parallel kind.", - "type": "string", - "enum": ["count", "collection"] - }, - "batchSize": { - "description": "Maximum concurrent branches.", - "type": "number" - }, - "enabled": { - "description": "Whether the parallel runs.", - "type": "boolean" - }, - "locked": { - "description": "Whether the parallel is locked against edits.", - "type": "boolean" + } } - }, - "required": ["id", "nodes"], - "title": "Workflow parallel", - "description": "A parallel container derived from the workflow blocks." + ] }, - "WorkflowVariableInput": { + "WorkflowPublicApiSettings": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Variable identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Variable name, referenced from block inputs." - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"], - "description": "Declared variable type." + "description": "Unique workflow identifier." }, - "value": { - "description": "Variable value; free-form and validated per `type` at use time." + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow accepts unauthenticated public API execution." } }, - "required": ["id", "name", "type", "value"], - "title": "Workflow variable", - "description": "A workflow-scoped variable." + "required": ["id", "isPublicApi"], + "additionalProperties": false, + "title": "Workflow public API settings", + "description": "Whether a deployed workflow is executable without an API key." }, - "ReplaceWorkflowStateRequest": { + "UpdateWorkflowPublicApiResponse": { "type": "object", "properties": { - "blocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowBlockInput" - }, - "description": "Blocks keyed by block id." - }, - "edges": { - "maxItems": 10000, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEdgeInput" - }, - "description": "Directed connections between blocks." - }, - "loops": { - "description": "Ignored on write: loop containers are recomputed from `blocks`.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowLoopInput" - } - }, - "parallels": { - "description": "Ignored on write: parallel containers are recomputed from `blocks`.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowParallelInput" - } - }, - "variables": { - "description": "Replacement variable set. Omit to leave the stored variables untouched.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowVariableInput" - } + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowPublicApiSettings" } }, - "required": ["blocks", "edges"], + "required": ["data"], "additionalProperties": false, - "title": "Replace workflow state request", - "description": "A complete replacement draft graph for a workflow.", + "title": "Update workflow public API response", + "description": "Public API access after the update.", "examples": [ { - "blocks": {}, - "edges": [] + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isPublicApi": true + } } ] }, - "WorkflowSkippedItem": { + "UpdateWorkflowPublicApiRequest": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": [ - "block_not_found", - "invalid_block_type", - "block_not_allowed", - "model_not_allowed", - "block_locked", - "tool_not_allowed", - "invalid_edge_target", - "invalid_edge_source", - "invalid_edge_scope", - "invalid_source_handle", - "invalid_target_handle", - "invalid_subblock_field", - "missing_required_params", - "invalid_subflow_parent", - "nested_subflow_not_allowed", - "duplicate_block_name", - "reserved_block_name", - "retry_not_supported", - "duplicate_trigger", - "duplicate_single_instance_block", - "disabled_ancestor" - ], - "description": "Machine-readable reason the engine declined an operation." - }, - "operationType": { - "type": "string", - "description": "The `operation_type` that was declined." - }, - "blockId": { - "type": "string", - "description": "Block the declined operation targeted." - }, - "reason": { - "type": "string", - "description": "Human-readable explanation." - }, - "details": { - "description": "Additional context for the reason; keys depend on `type`.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One piece of engine-supplied context for the reason." - } + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow should accept unauthenticated public API execution." } }, - "required": ["type", "operationType", "blockId", "reason"], + "required": ["isPublicApi"], "additionalProperties": false, - "title": "Workflow skipped item", - "description": "One operation the edit engine did not apply." - }, - "WorkflowInputValidationError": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block whose input was rejected." - }, - "blockType": { - "type": "string", - "description": "Type of the block whose input was rejected." - }, - "field": { - "type": "string", - "description": "Sub-block field that was rejected." - }, - "error": { - "type": "string", - "description": "Why the value was rejected." + "title": "Update workflow public API request", + "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "examples": [ + { + "isPublicApi": true } - }, - "required": ["blockId", "blockType", "field", "error"], - "additionalProperties": false, - "title": "Workflow input validation error", - "description": "One block input that was dropped rather than persisted." + ] }, - "ApplyWorkflowOperationsResult": { + "DeployResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the workflow whose draft graph was written." - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "needsRedeployment": { + "isDeployed": { "type": "boolean", - "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." - }, - "applied": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Operations the engine applied." - }, - "skipped": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowSkippedItem" - }, - "description": "Operations the engine declined. Empty when everything applied." + "description": "Whether a workflow version is currently live and available for API execution." }, - "deferred": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowSkippedItem" - }, - "description": "Edges waiting for target blocks. They apply automatically when their targets exist, in this batch or a later one. Do not resubmit them." + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "inputValidationErrors": { + "warnings": { "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowInputValidationError" - }, - "description": "Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead." - }, - "mintedBlockIds": { - "type": "object", - "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "string", - "description": "The id the block was actually given." - }, - "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "lint": { - "$ref": "#/components/schemas/WorkflowLintReport" + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." }, - "dryRun": { - "type": "boolean", - "description": "Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce." + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." + }, + "version": { + "description": "Deployment version created for this attempt, when available.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, "required": [ "id", + "isDeployed", + "deployedAt", "warnings", - "needsRedeployment", - "applied", - "skipped", - "deferred", - "inputValidationErrors", - "mintedBlockIds", - "lint", - "dryRun" + "activeDeployment", + "latestDeploymentAttempt" ], "additionalProperties": false, - "title": "Apply workflow operations result", - "description": "Outcome of a batch of semantic edits against a workflow graph." + "title": "Deploy result", + "description": "Deployment attempt accepted for asynchronous activation. `latestDeploymentAttempt` identifies the attempt. Poll Get Workflow Deployment for `isDeployed` and `deployedAt`, or List Workflow Versions for `isActive`." }, - "ApplyWorkflowOperationsResponse": { + "DeployWorkflowResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + "$ref": "#/components/schemas/DeployResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Apply workflow operations response", - "description": "Outcome of a batch of semantic edits.", + "title": "Deploy workflow response", + "description": "Current deployment state after accepting the attempt.", "examples": [ { "data": { "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "applied": 1, - "skipped": [], - "deferred": [], - "inputValidationErrors": [], - "mintedBlockIds": { - "triage": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" - }, - "lint": { - "sources": [], - "sinks": [], - "orphanBlocks": [], - "emptyOutgoingPorts": [], - "invalidBranchPorts": [], - "invalidConnectionTargets": [], - "fieldIssues": [ - { - "blockId": "agent-1", - "blockName": "Triage", - "blockType": "agent", - "missingRequiredFields": ["systemPrompt"], - "inactiveModeValues": [] - } - ], - "unresolvedReferences": [], - "notes": [] - }, + "isDeployed": false, + "deployedAt": null, "warnings": [], - "needsRedeployment": true, - "dryRun": false - } - } - ] - }, - "WorkflowEditOperation": { - "oneOf": [ - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "add", - "description": "Create a new block." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." - }, - "params": { - "type": "object", - "properties": { - "type": { - "type": "string", - "minLength": 1, - "description": "Registered block type." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Block display name." - }, - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "required": ["type", "name"], - "additionalProperties": { - "description": "One block-specific input or connection descriptor." + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "action": "deploy", + "status": "preparing", + "isCurrent": true, + "readiness": { + "webhooks": "pending", + "schedules": "ready", + "mcp": "not_applicable" }, - "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." - } - }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "edit", - "description": "Change an existing block: its inputs, name, or connections." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null }, - "params": { - "allOf": [ - { - "type": "object", - "properties": { - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "additionalProperties": { - "description": "One operation parameter; see the description for the accepted keys." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One operation parameter; see the description for the accepted keys." - } - } - ], - "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." - } - }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false - }, + "version": 3 + } + } + ] + }, + "DeployWorkflowRequest": { + "default": {}, + "title": "Deploy workflow request", + "description": "Optional metadata for the new deployment version.", + "examples": [ { - "type": "object", - "properties": { - "operation_type": { + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + ], + "type": "object", + "properties": { + "name": { + "description": "Optional label for the deployment version.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "description": "Optional release note for the deployment version.", + "anyOf": [ + { "type": "string", - "const": "delete", - "description": "Remove a block and every edge touching it." + "maxLength": 50000 }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "UndeployResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] + }, + "warnings": { + "type": "array", + "items": { + "type": "string" }, - "required": ["operation_type", "block_id"], - "additionalProperties": false + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "insert_into_subflow", - "description": "Create a block inside a loop or parallel container." + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." + }, + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" }, - "params": { - "type": "object", - "properties": { - "subflowId": { - "type": "string", - "minLength": 1, - "description": "Loop or parallel container to insert the block into." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Registered block type." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Block display name." - }, - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "required": ["subflowId", "type", "name"], - "additionalProperties": { - "description": "One block-specific input or connection descriptor." - }, - "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + { + "type": "null" } - }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], + "additionalProperties": false, + "title": "Undeploy result", + "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active." + }, + "UndeployWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UndeployResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Undeploy workflow response", + "description": "Deployment state after deactivating the active version.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } + } + ] + }, + "RollbackWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RollbackResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Rollback workflow response", + "description": "Current deployment state after accepting the rollback attempt.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", + "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", + "version": 2, + "action": "activate", + "status": "activating", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null + }, + "version": 2 + } + } + ] + }, + "RollbackWorkflowRequest": { + "default": {}, + "title": "Rollback workflow request", + "description": "Optional deployment version to reactivate.", + "examples": [ + { + "version": 2 + } + ], + "type": "object", + "properties": { + "version": { + "description": "Deployment version to reactivate. Omit to select the previous active version.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + } + }, + "additionalProperties": false + }, + "WorkflowExportPayload": { + "type": "object", + "properties": { + "version": { + "type": "string", + "const": "1.0", + "description": "Workflow export format version." }, - { + "exportedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the export was created.", + "format": "date-time" + }, + "workflow": { "type": "object", "properties": { - "operation_type": { + "id": { "type": "string", - "const": "extract_from_subflow", - "description": "Move a block out of its loop or parallel container." + "description": "Identifier of the source workflow." }, - "block_id": { + "name": { "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + "description": "Name of the exported workflow." }, - "params": { - "type": "object", - "properties": { - "subflowId": { - "type": "string", - "minLength": 1, - "description": "Loop or parallel container the block moves into or out of." + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } + ], + "description": "Description of the exported workflow, or null when unset." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Identifier of the source workspace, or null for legacy exports." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096 + } + }, + "required": ["id", "name", "description", "workspaceId", "folderPath"], + "additionalProperties": false, + "description": "Source workflow metadata." + }, + "state": { + "type": "object", + "additionalProperties": true, + "description": "Secret-sanitized workflow graph, edges, loops, parallels, metadata, and variables." + }, + "referenceManifest": { + "description": "Versioned non-secret identifiers and registered source field occurrences for mapped import.", + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 1, + "description": "Reference format or deployment version number." + }, + "references": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrences": { + "minItems": 1, + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false + }, + "description": "Every registered source block and field occurrence of this reference." + } + }, + "required": ["kind", "sourceId", "required", "occurrences"], + "additionalProperties": false }, - "required": ["subflowId"], - "additionalProperties": { - "description": "One block-specific input." - }, - "description": "Container identifier, plus any block-specific inputs." + "description": "Non-secret resource identifiers and their registered occurrences." } }, - "required": ["operation_type", "block_id", "params"], + "required": ["version", "references"], "additionalProperties": false } - ], - "title": "Workflow edit operation", - "description": "One semantic edit against a workflow graph." - }, - "AgentToolInput": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentTool" }, - "description": "The complete value stored in an Agent block’s `tools` input.", - "title": "Agent tools input" + "required": ["version", "exportedAt", "workflow", "state"], + "additionalProperties": false, + "title": "Workflow export payload", + "description": "Portable, secret-sanitized workflow export. Workspace-scoped bindings must be selected again after import." }, - "AgentTool": { - "oneOf": [ - { - "$ref": "#/components/schemas/AgentIntegrationTool" - }, - { - "$ref": "#/components/schemas/AgentCustomTool" - }, - { - "$ref": "#/components/schemas/AgentMcpTool" - }, + "ExportWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowExportPayload" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Export workflow response", + "description": "Portable, secret-sanitized workflow data.", + "examples": [ { - "$ref": "#/components/schemas/AgentMcpServerAdvanced" + "data": { + "version": "1.0", + "exportedAt": "2026-08-09T18:04:11.000Z", + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderPath": "/Operations" + }, + "state": { + "blocks": {}, + "edges": [] + } + } } - ], - "title": "Agent tool", - "description": "A catalog integration operation, workspace custom tool, or MCP tool available to an Agent." + ] }, - "AgentIntegrationTool": { + "ImportedWorkflow": { "type": "object", "properties": { - "type": { + "id": { + "type": "string", + "description": "Resource identifier." + }, + "name": { + "type": "string", + "description": "Display name of the workflow or workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Imported workflow description." + }, + "workspaceId": { + "type": "string", + "description": "Explicit current workspace scope." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path.", + "maxLength": 4096 + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was imported.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "operationId": { "type": "string", "minLength": 1, - "maxLength": 255, - "pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$", - "description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id." + "maxLength": 256, + "description": "Durable operation identifier to use for polling." }, - "operation": { - "description": "Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.", + "requestId": { "type": "string", "minLength": 1, - "maxLength": 255 + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." }, - "usageControl": { + "kind": { "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "enum": ["workflow_import", "workspace_fork", "workspace_push", "workspace_pull"], + "description": "Resource or operation kind." + }, + "applied": { + "type": "boolean", + "const": true, + "description": "The business transaction committed, including when follow-up work fails." + }, + "status": { + "type": "string", + "enum": [ + "processing", + "completed", + "completed_with_warnings", + "requires_configuration", + "failed" + ], + "description": "Current operation or deployment outcome." + }, + "resourceIds": { + "maxItems": 5000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": "Identifiers of resources created or changed by the committed operation." + }, + "issues": { + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable machine-readable issue code." + }, + "message": { + "type": "string", + "maxLength": 2048, + "description": "Human-readable explanation of the issue." + }, + "workflowId": { + "description": "Workflow affected by this issue or deployment attempt.", + "type": "string", + "maxLength": 256 + }, + "blockId": { + "description": "Source block identifier before graph ID regeneration.", + "type": "string", + "maxLength": 256 + }, + "subBlockKey": { + "description": "Registered source field key, including the tool index for nested Agent fields.", + "type": "string", + "maxLength": 256 + } + }, + "required": ["code", "message"], + "additionalProperties": false + }, + "description": "Structured warnings, missing configuration, and follow-up failures." }, - "params": { + "idMap": { + "description": "Source graph identifiers mapped to the imported identifiers.", "type": "object", "propertyNames": { - "type": "string" + "type": "string", + "maxLength": 256 }, "additionalProperties": { - "description": "One tool parameter value." - }, - "description": "Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them." - } - }, - "required": ["type"], - "additionalProperties": { - "description": "Forward-compatible integration tool metadata preserved by the workflow editor." - }, - "title": "Agent integration tool", - "description": "A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.", - "examples": [ - { - "type": "cloudwatch", - "operation": "describe_alarm_history", - "usageControl": "auto", - "params": {} - } - ] - }, - "AgentCustomTool": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "custom-tool", - "description": "Custom-tool discriminator." + "type": "string", + "maxLength": 256 + } + }, + "deploymentOperationIds": { + "description": "Exact deployment attempts admitted by the workspace operation.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + }, + "deployments": { + "description": "Readiness of the exact admitted deployment attempts.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + }, + "workflowId": { + "type": "string", + "maxLength": 256, + "description": "Workflow affected by this issue or deployment attempt." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Reference format or deployment version number." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current operation or deployment outcome." + }, + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "pendingComponents": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + }, + "description": "Deployment components that have not finished becoming ready." + } }, - "customToolId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Custom tool ID from List Custom Tools." + "required": [ + "operationId", + "workflowId", + "version", + "status", + "ready", + "pendingComponents" + ], + "additionalProperties": false + } + }, + "triggerUrlChanges": { + "description": "Public trigger paths changed by this sync, with the affected workflow names.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the workflow whose public trigger path stops serving." + }, + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } }, - "usageControl": { - "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." - } - }, - "required": ["type", "customToolId"], - "additionalProperties": { - "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + "required": ["workflowName", "path"], + "additionalProperties": false } }, - { + "backgroundWorkId": { + "description": "Workspace activity identifier for resource-copy progress.", + "type": "string", + "maxLength": 256 + }, + "copyProgress": { + "description": "Completion status and counts for explicitly selected resource copies.", "type": "object", "properties": { - "type": { + "status": { "type": "string", - "const": "custom-tool", - "description": "Custom-tool discriminator." - }, - "schema": { - "type": "object", - "properties": { - "type": { - "description": "Function declaration discriminator.", - "type": "string", - "const": "function" - }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "Function name presented to the model." - }, - "description": { - "description": "What the inline custom tool does.", - "type": "string" - }, - "parameters": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One JSON Schema keyword on the function parameters." - }, - "description": "JSON Schema describing the function arguments." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Additional function declaration metadata." - }, - "description": "OpenAI-style function definition." - } - }, - "required": ["function"], - "additionalProperties": { - "description": "Additional custom tool declaration metadata." - }, - "description": "Inline OpenAI-style function declaration." + "enum": ["pending", "completed", "failed"], + "description": "Current operation or deployment outcome." }, - "code": { - "type": "string", - "description": "Inline tool implementation executed by the Function runtime." + "copied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources copied successfully." }, - "usageControl": { - "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "failed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources that failed to copy." } }, - "required": ["type", "schema", "code"], - "additionalProperties": { - "description": "Forward-compatible custom tool metadata preserved by the workflow editor." - } + "required": ["status", "copied", "failed"], + "additionalProperties": false } + }, + "required": [ + "id", + "name", + "description", + "workspaceId", + "folderPath", + "createdAt", + "updatedAt" ], - "title": "Agent custom tool", - "description": "A workspace custom tool. Prefer `customToolId`; inline declarations are also accepted.", + "additionalProperties": false, + "title": "Imported workflow", + "description": "Workflow created by an import operation." + }, + "ImportWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ImportedWorkflow" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Import workflow response", + "description": "The workflow created by the import.", "examples": [ { - "type": "custom-tool", - "customToolId": "cst_01J9X2ABCDEF", - "usageControl": "auto" + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderPath": "/Operations", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } } ] }, - "AgentMcpTool": { + "ImportWorkflowBody": { "type": "object", "properties": { - "type": { + "workspaceId": { "type": "string", - "const": "mcp", - "description": "MCP-tool discriminator." + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to import the workflow." }, - "params": { - "allOf": [ + "workflow": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "description": "JSON string containing a workflow export object or bare workflow state." + }, { "type": "object", - "properties": { - "serverId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "MCP server id returned by `GET /api/v2/mcp-servers`." + "additionalProperties": true, + "description": "Workflow export object or bare workflow state." + } + ], + "description": "Workflow export object, bare workflow state, or JSON string containing either form." + }, + "folderPath": { + "description": "Destination folder path; omit for the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "name": { + "description": "Override for the imported workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "description": "Override for the imported workflow description.", + "type": "string", + "maxLength": 2000 + }, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["kind", "sourceId", "targetId"], + "additionalProperties": false + } + }, + "bindings": { + "description": "Resolved and unresolved source occurrences with their destination selections.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "default": [], + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] }, - "toolName": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Tool name returned by the MCP server’s tools endpoint." + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 } }, - "required": ["serverId", "toolName"], - "additionalProperties": { - "description": "One parameter fixed by the workflow author." + "encoding": { + "default": "scalar", + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + }, + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." } }, - { - "type": "object", - "propertyNames": { - "type": "string" + "required": ["blockId", "subBlockKey", "kind", "targetId"], + "additionalProperties": false + } + }, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." }, - "additionalProperties": { - "description": "One parameter fixed by the workflow author." + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 16384, + "description": "Destination value for the registered dependent field." } - } - ], - "description": "MCP server and tool identity plus any tool arguments fixed by the workflow author." + }, + "required": ["blockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - "usageControl": { + "requestId": { + "description": "Stable client request ID for reconciliation and identical retries.", "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "previewFingerprint": { + "description": "Fingerprint of the reviewed preview and its choices.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" } }, - "required": ["type", "params"], - "additionalProperties": { - "description": "Forward-compatible MCP tool metadata preserved by the workflow editor." - }, - "title": "Agent MCP tool", - "description": "One tool discovered from a workspace MCP server.", - "examples": [ - { - "type": "mcp", - "params": { - "serverId": "mcp_01J9X2ABCDEF", - "toolName": "search_docs" - }, - "usageControl": "auto" - } - ] + "required": ["workspaceId", "workflow"], + "additionalProperties": false, + "title": "Import workflow input", + "description": "Workflow document, destination, and optional reviewed mappings." }, - "AgentMcpServerAdvanced": { + "ChatDeploymentListItem": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "const": "mcp-server-advanced", - "description": "Server-wide MCP binding discriminator." + "description": "Unique chat deployment identifier." }, - "params": { - "type": "object", - "properties": { - "serverId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID." - } + "workflowId": { + "type": "string", + "description": "Workflow this deployment publishes." + }, + "workspaceId": { + "type": "string", + "description": "Workspace the deployment belongs to, derived from its workflow." + }, + "identifier": { + "type": "string", + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "required": ["serverId"], - "additionalProperties": false, - "description": "Server identity for discovering and invoking every available MCP tool." + "description": "Block outputs surfaced to visitors." }, - "usageControl": { + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." + }, + "createdAt": { "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "description": "ISO 8601 timestamp when the deployment was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was last modified.", + "format": "date-time" } }, - "required": ["type", "params"], - "additionalProperties": { - "description": "Forward-compatible MCP server metadata preserved by the workflow editor." - }, - "title": "Agent MCP server (advanced)", - "description": "All tools available to the executing subject from one MCP server.", - "examples": [ - { - "type": "mcp-server-advanced", - "params": { - "serverId": "mcp_01J9X2ABCDEF" - }, - "usageControl": "auto" - } - ] + "required": [ + "id", + "workflowId", + "workspaceId", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "outputConfigs", + "includeThinking", + "includeToolCalls", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Chat deployment list entry", + "description": "A workflow published as a hosted chat, without the fields the detail read gates." }, - "ApplyWorkflowOperationsRequest": { + "StoredChatDeploymentOutputConfig": { "type": "object", "properties": { - "operations": { - "minItems": 1, - "maxItems": 200, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEditOperation" - }, - "description": "Edits to apply, in a single batch." - }, - "atomic": { - "default": false, - "description": "Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.", - "type": "boolean" + "workflowId": { + "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", + "type": "string" }, - "layout": { - "default": "targeted", - "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", + "blockId": { "type": "string", - "enum": ["targeted", "none"] + "description": "Block whose output the chat streams." }, - "setBlockEnabled": { - "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", - "maxItems": 200, + "path": { + "type": "string", + "description": "Path within that block output. Empty means the whole output." + } + }, + "required": ["blockId", "path"], + "additionalProperties": false, + "title": "Stored chat deployment output config", + "description": "One block output currently surfaced to chat visitors." + }, + "ChatDeploymentListResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "object", - "properties": { - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." - }, - "enabled": { - "type": "boolean", - "description": "Whether the block should run." - } + "$ref": "#/components/schemas/ChatDeploymentListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "required": ["block_id", "enabled"], - "additionalProperties": false - } + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["operations"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Apply workflow operations request", - "description": "A batch of semantic edits against a workflow graph.", + "title": "Chat deployment list response", + "description": "A cursor-paginated page of chat deployments.", "examples": [ { - "operations": [ + "data": [ { - "operation_type": "add", - "block_id": "agent-1", - "params": { - "type": "agent", - "name": "Triage", - "inputs": { - "tools": [ - { - "type": "cloudwatch", - "operation": "describe_alarm_history", - "usageControl": "auto", - "params": {} - } - ] + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" } - } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" } - ] + ], + "nextCursor": null } ] }, - "ApplyWorkflowVariablesResult": { + "StoredChatDeploymentCustomizations": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Identifier of the workflow whose variables were updated." + "primaryColor": { + "description": "CSS color used for the chat accent.", + "type": "string" }, - "variableCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Variables the workflow now holds." + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string" }, - "changed": { - "type": "boolean", - "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." - } - }, - "required": ["id", "variableCount", "changed"], - "additionalProperties": false, - "title": "Apply workflow variables result", - "description": "Outcome of a workflow variable update." - }, - "ApplyWorkflowVariablesResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string" } }, - "required": ["data"], "additionalProperties": false, - "title": "Apply workflow variables response", - "description": "Outcome of a workflow variable update.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "variableCount": 3, - "changed": true - } - } - ] + "title": "Stored chat deployment customizations", + "description": "Presentation overrides currently stored on the deployed chat." }, - "ApplyWorkflowVariablesRequest": { + "ChatDeployment": { "type": "object", "properties": { - "operations": { - "minItems": 1, - "maxItems": 100, + "id": { + "type": "string", + "description": "Unique chat deployment identifier." + }, + "workflowId": { + "type": "string", + "description": "Workflow this deployment publishes." + }, + "workspaceId": { + "type": "string", + "description": "Workspace the deployment belongs to, derived from its workflow." + }, + "identifier": { + "type": "string", + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored. The password itself is never readable." + }, + "allowedEmails": { "type": "array", "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "add", - "description": "Create a variable with this name." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Variable name." - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"], - "description": "Declared variable type." - }, - "value": { - "description": "Variable value, coerced to `type`." - } - }, - "required": ["operation", "name", "type", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "edit", - "description": "Replace the value, and optionally the type, of an existing variable." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name of the variable to update." - }, - "type": { - "description": "Replacement type; the stored type is kept when omitted.", - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"] - }, - "value": { - "description": "Replacement value, coerced to the effective type." - } - }, - "required": ["operation", "name", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "delete", - "description": "Remove the variable with this name." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name of the variable to remove." - } - }, - "required": ["operation", "name"], - "additionalProperties": false - } - ], - "description": "One variable change." + "type": "string" + }, + "description": "Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise." + }, + "customizations": { + "description": "Presentation overrides. Unset fields fall back to platform defaults.", + "$ref": "#/components/schemas/StoredChatDeploymentCustomizations" + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "description": "Variable changes to apply, in order." + "description": "Block outputs surfaced to visitors." + }, + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was last modified.", + "format": "date-time" } }, - "required": ["operations"], + "required": [ + "id", + "workflowId", + "workspaceId", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "hasPassword", + "allowedEmails", + "customizations", + "outputConfigs", + "includeThinking", + "includeToolCalls", + "createdAt", + "updatedAt" + ], "additionalProperties": false, - "title": "Apply workflow variables request", - "description": "Additions, edits, and deletions against a workflow’s variables." + "title": "Chat deployment", + "description": "A workflow published as a hosted chat." }, - "DuplicateWorkflowResponse": { + "GetWorkflowChatDeploymentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/ChatDeployment" } }, "required": ["data"], "additionalProperties": false, - "title": "Duplicate workflow response", - "description": "The created copy.", + "title": "Get workflow chat deployment response", + "description": "The workflow's chat deployment.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage (copy)", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" } } ] }, - "DuplicateWorkflowRequest": { + "ReplaceWorkflowChatDeploymentResponse": { "type": "object", "properties": { - "name": { - "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ChatDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow chat deployment response", + "description": "The chat deployment as stored after the replace.", + "examples": [ + { + "data": { + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "ChatDeploymentCustomizations": { + "type": "object", + "properties": { + "primaryColor": { + "description": "CSS color used for the chat accent.", "type": "string", "minLength": 1, - "maxLength": 255 + "maxLength": 64 }, - "folderPath": { - "description": "Destination folder path. Defaults to the source workflow's folder.", - "$ref": "#/components/schemas/FolderPathInput" + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string", + "maxLength": 2000 + }, + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string", + "maxLength": 2048 } }, "additionalProperties": false, - "title": "Duplicate workflow request", - "description": "Optional name and destination folder for the copy." + "title": "Chat deployment customizations", + "description": "Presentation overrides for the deployed chat." }, - "RestoreWorkflowResponse": { + "ChatDeploymentOutputConfig": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "workflowId": { + "description": "Child workflow containing the selected block. Omit for the deployed workflow.", + "type": "string", + "minLength": 1 + }, + "blockId": { + "type": "string", + "minLength": 1, + "description": "Block whose output the chat streams." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Path within that block output." } }, - "required": ["data"], + "required": ["blockId", "path"], "additionalProperties": false, - "title": "Restore workflow response", - "description": "The restored workflow.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "title": "Chat deployment output config", + "description": "One block output surfaced to chat visitors." + }, + "ReplaceChatDeploymentRequest": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9-]+$", + "description": "URL slug the deployed chat answers on. Must be free across live deployments." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Title shown to visitors." + }, + "description": { + "description": "Description shown to visitors. Omitted clears it.", + "type": "string", + "maxLength": 2000 + }, + "customizations": { + "description": "Presentation overrides. Omitted fields take platform defaults.", + "$ref": "#/components/schemas/ChatDeploymentCustomizations" + }, + "authType": { + "description": "How visitors are gated. `public` leaves the chat open to anyone holding the URL.", + "default": "public", + "type": "string", + "enum": ["public", "password", "email", "sso"] + }, + "password": { + "description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "allowedEmails": { + "description": "Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "outputConfigs": { + "description": "Block outputs to surface to visitors. Omitted surfaces none.", + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatDeploymentOutputConfig" } + }, + "includeThinking": { + "description": "Allow visitors to receive provider thinking events.", + "default": false, + "type": "boolean" + }, + "includeToolCalls": { + "description": "Allow visitors to receive tool lifecycle events.", + "default": false, + "type": "boolean" + } + }, + "required": ["identifier", "title"], + "additionalProperties": false, + "title": "Replace chat deployment request", + "description": "The complete desired state of a workflow's chat.", + "examples": [ + { + "identifier": "support", + "title": "Support chat" } ] }, - "MoveWorkflowsResult": { + "DeleteChatDeploymentResult": { "type": "object", "properties": { - "moved": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Workflows that were relocated." - }, - "failed": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." - }, - "folderPath": { + "id": { "type": "string", - "title": "Folder path", - "description": "Canonical destination folder path.", - "maxLength": 4096 + "description": "Identifier of the removed chat deployment." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the deployment was removed." } }, - "required": ["moved", "failed", "folderPath"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Move workflows result", - "description": "Which workflows moved and which did not." + "title": "Delete chat deployment result", + "description": "Chat deployment removal acknowledgement." }, - "MoveWorkflowsResponse": { + "DeleteWorkflowChatDeploymentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/MoveWorkflowsResult" + "$ref": "#/components/schemas/DeleteChatDeploymentResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Move workflows response", - "description": "Which workflows moved and which did not.", + "title": "Delete workflow chat deployment response", + "description": "Acknowledgement that the chat deployment was removed.", "examples": [ { "data": { - "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], - "failed": [], - "folderPath": "/Operations" + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true } } ] }, - "MoveWorkflowsRequest": { + "ExecutionError": { "type": "object", "properties": { - "workspaceId": { + "message": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace holding every workflow in the batch." - }, - "workflowIds": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Workflows to move. Duplicates are collapsed." + "description": "Human-readable workflow execution failure message." }, - "folderPath": { - "description": "Destination folder path; `/` moves the workflows to the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId", "workflowIds", "folderPath"], - "additionalProperties": false, - "title": "Move workflows request", - "description": "Workflows to relocate and the folder to relocate them into." - }, - "WorkflowInputField": { - "type": "object", - "properties": { - "name": { + "code": { "type": "string", - "description": "Input field name." + "enum": [ + "TIMEOUT", + "CANCELLED", + "USAGE_LIMIT_EXCEEDED", + "INVALID_INPUT", + "BLOCK_EXECUTION_FAILED", + "CHILD_WORKFLOW_FAILED", + "EXECUTION_FAILED" + ], + "description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`." }, - "type": { - "type": "string", - "description": "Input field type." + "blockId": { + "description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.", + "type": "string" }, - "description": { - "description": "Optional input field description.", + "blockName": { + "description": "Display name of the failing block. Present on the synchronous execute response only.", + "type": "string" + }, + "blockType": { + "description": "Integration or block type that failed. Present on the synchronous execute response only.", "type": "string" } }, - "required": ["name", "type"], + "required": ["message", "code"], "additionalProperties": false, - "title": "Workflow input field", - "description": "A deployed API trigger input exposed by a workflow." + "title": "Execution error", + "description": "Structured in-band failure details for a workflow run." }, - "WorkflowDetail": { + "WorkflowRunResult": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." - }, - "name": { + "runId": { "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "folderPath": { + "workflowId": { "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "description": "Workflow that produced the run." }, - "workspaceId": { + "status": { "type": "string", - "description": "Workspace that owns the workflow." - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + "enum": ["completed", "failed", "paused", "cancelled"], + "description": "Terminal or paused run status." }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "output": { + "description": "Workflow output, including partial output on failure." }, - "lastRunAt": { + "error": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ExecutionError" }, { "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" - }, - "variables": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Structured workflow variable value." - }, - "description": "Workflow-scoped variables keyed by variable identifier." + "description": "Structured execution failure, or null when none occurred." }, - "inputs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowInputField" - }, - "description": "Input fields exposed by the workflow API trigger." - } - }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt", - "variables", - "inputs" - ], - "additionalProperties": false, - "title": "Workflow detail", - "description": "Full workflow summary with variables and API-trigger input fields." - }, - "WorkflowDetailResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowDetail" + "startedAt": { + "description": "ISO 8601 timestamp when execution started.", + "format": "date-time", + "type": "string" + }, + "endedAt": { + "description": "ISO 8601 timestamp when execution ended.", + "format": "date-time", + "type": "string" + }, + "durationMs": { + "description": "Execution duration in milliseconds.", + "type": "number", + "minimum": 0 } }, - "required": ["data"], + "required": ["runId", "workflowId", "status", "output", "error"], "additionalProperties": false, - "title": "Workflow detail response", - "description": "Detailed workflow metadata, variables, and trigger inputs.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z", - "variables": {}, - "inputs": [] - } - } - ] + "title": "Workflow run result", + "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." }, - "UpdateWorkflowResponse": { + "ExecuteWorkflowSyncResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/WorkflowRunResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow response", - "description": "The updated workflow summary.", + "title": "Synchronous workflow execution response", + "description": "Completed, failed, paused, or cancelled synchronous workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "output": { + "result": "Ticket routed to Support" + }, + "error": null, + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000 } } ] }, - "UpdateWorkflowRequest": { + "QueuedWorkflowRun": { "type": "object", "properties": { - "name": { - "description": "Replacement workflow name.", + "runId": { "type": "string", "minLength": 1, - "maxLength": 255 - }, - "description": { - "description": "Replacement workflow description; null clears it.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "folderPath": { - "description": "Destination folder path; `/` moves the workflow to the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "additionalProperties": false, - "title": "Update workflow request", - "description": "Fields to update on an existing workflow." - }, - "DeleteWorkflowResult": { - "type": "object", - "properties": { - "id": { + "statusUrl": { "type": "string", - "description": "Identifier of the archived workflow." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the workflow is no longer live." - }, - "archived": { - "type": "boolean", - "const": true, - "description": "Whether the workflow was archived. Restore Workflow recovers it and the schedules, webhooks, MCP tools, and chats archived with it." + "format": "uri", + "description": "Absolute URL of the workflow run resource." } }, - "required": ["id", "deleted", "archived"], + "required": ["runId", "statusUrl"], "additionalProperties": false, - "title": "Delete workflow result", - "description": "Confirmation that a workflow was archived." + "title": "Queued workflow run", + "description": "Receipt returned when a workflow run is queued." }, - "DeleteWorkflowResponse": { + "ExecuteWorkflowQueuedResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowResult" + "$ref": "#/components/schemas/QueuedWorkflowRun" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow response", - "description": "Confirmation that the workflow was archived.", + "title": "Queued workflow execution response", + "description": "Receipt returned for an asynchronous workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true, - "archived": true + "runId": "run_8f14e45f-ceea-467f-a", + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" } } ] }, - "WorkflowVersion": { + "ExecuteWorkflowRequest": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique deployment-version identifier." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." - }, - "name": { - "description": "Optional deployment-version label.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional deployment-version release note.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version was created.", - "format": "date-time" + "input": { + "description": "Workflow input keyed by the selected trigger input-field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Value supplied for one workflow input field." + } }, - "deployedBy": { - "description": "Display name of the user who created the deployment, when available.", - "anyOf": [ + "run": { + "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", + "oneOf": [ { - "type": "string" + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "deployment", + "description": "Execute the active deployed workflow state." + } + }, + "required": ["source"], + "additionalProperties": false }, { - "type": "null" + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "manual", + "description": "Execute the current saved workflow state manually." + }, + "entry": { + "description": "Manual entry mode. Omit to enter through the workflow trigger; a block entry requires an exact source run.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "trigger", + "description": "Enter the manual run through a runnable trigger." + }, + "blockId": { + "description": "Runnable trigger block to enter through. Omit only when the saved workflow has exactly one runnable trigger.", + "type": "string", + "minLength": 1 + }, + "useMockPayload": { + "description": "Use the selected trigger's server-derived mock payload. Cannot be combined with `input`.", + "type": "boolean" + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "block", + "description": "Resume manual execution at a block using persisted upstream state." + }, + "blockId": { + "type": "string", + "minLength": 1, + "description": "Saved workflow block at which manual execution should resume." + }, + "sourceRunId": { + "type": "string", + "minLength": 1, + "description": "Run ID supplying upstream block results when starting from a selected block." + } + }, + "required": ["type", "blockId", "sourceRunId"], + "additionalProperties": false + } + ] + } + }, + "required": ["source"], + "additionalProperties": false } ] }, - "latestOperationStatus": { - "description": "Latest lifecycle-operation status for this version.", - "anyOf": [ - { - "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"] - }, - { - "type": "null" - } - ] - } - }, - "required": ["id", "version", "isActive", "createdAt"], - "additionalProperties": false, - "title": "Workflow version", - "description": "A saved deployment version of a workflow." - }, - "WorkflowVersionListResponse": { - "type": "object", - "properties": { - "data": { + "async": { + "default": false, + "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", + "type": "boolean" + }, + "executionTimeoutSeconds": { + "description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.", + "type": "integer", + "minimum": 1, + "maximum": 604800 + }, + "stream": { + "default": false, + "description": "Return Server-Sent Events instead of JSON when true. Cannot be combined with `async`.", + "type": "boolean" + }, + "selectedOutputs": { + "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowVersion" - }, - "description": "Items in the current page." + "type": "string", + "minLength": 1 + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "includeThinking": { + "default": false, + "description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", + "type": "boolean" + }, + "includeToolCalls": { + "default": false, + "description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", + "type": "boolean" + }, + "includeFileBase64": { + "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", + "type": "boolean" + }, + "base64MaxBytes": { + "description": "Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 16777216 } }, - "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Workflow version list response", - "description": "A cursor-paginated page of deployment versions.", + "title": "Execute workflow request", + "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", "examples": [ { - "data": [ - { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "deployedBy": "Jane Smith", - "latestOperationStatus": "active" + "input": { + "ticketId": "ticket_123" + } + }, + { + "input": { + "ticketId": "ticket_123" + }, + "async": true + }, + { + "input": { + "ticketId": "ticket_123" + }, + "stream": true + }, + { + "run": { + "source": "manual" + } + }, + { + "run": { + "source": "manual", + "entry": { + "type": "block", + "blockId": "block_123", + "sourceRunId": "run_123" } - ], - "nextCursor": null + } } ] }, - "DeployedWorkflowState": { - "title": "Deployed workflow state", - "description": "Workflow graph snapshot pinned by a deployment version.", - "type": "object", - "additionalProperties": true - }, - "WorkflowVersionDetail": { + "WorkflowRunListItem": { "type": "object", "properties": { - "id": { + "runId": { "type": "string", - "description": "Unique deployment-version identifier." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." + "workflowId": { + "type": "string", + "description": "Workflow that produced the run." }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "paused", + "redacting", + "completed", + "failed", + "cancelled" ], - "description": "Version label, or null when unset." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, - "description": { + "trigger": { + "type": "string", + "description": "Trigger type that started the run." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the run started.", + "format": "date-time" + }, + "endedAt": { "anyOf": [ { "type": "string" @@ -7437,214 +11728,126 @@ "type": "null" } ], - "description": "Version release note, or null when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version was created.", + "description": "ISO 8601 timestamp when the run ended, or null while active.", "format": "date-time" }, - "state": { - "description": "Workflow graph saved with this deployment version. Sensitive values are redacted to null.", - "$ref": "#/components/schemas/DeployedWorkflowState" - } - }, - "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], - "additionalProperties": false, - "title": "Workflow version detail", - "description": "A deployment version together with the workflow state it pins." - }, - "WorkflowVersionDetailResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowVersionDetail" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Workflow version detail response", - "description": "The deployment version and its pinned workflow graph.", - "examples": [ - { - "data": { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "state": { - "blocks": {}, - "edges": [] - } - } - } - ] - }, - "WorkflowVersionMetadata": { - "type": "object", - "properties": { - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." - }, - "name": { + "durationMs": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "description": "Version label, or null when unset." + "description": "Run duration in milliseconds, or null while active." }, - "description": { + "cost": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total credits consumed by the run." + } + }, + "required": ["total"], + "additionalProperties": false }, { "type": "null" } ], - "description": "Version release note, or null when unset." + "description": "Credit cost, or null when unavailable." } }, - "required": ["version", "name", "description"], + "required": [ + "runId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "cost" + ], "additionalProperties": false, - "title": "Workflow version metadata", - "description": "Mutable label and release note of a deployment version." + "title": "Workflow run summary", + "description": "Summary of a recorded workflow run." }, - "UpdateWorkflowVersionResponse": { + "WorkflowRunListResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowVersionMetadata" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update workflow version response", - "description": "The deployment version metadata after the update.", - "examples": [ - { - "data": { - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch." - } - } - ] - }, - "UpdateWorkflowVersionRequest": { - "type": "object", - "properties": { - "name": { - "description": "New label for the deployment version.", - "type": "string", - "minLength": 1, - "maxLength": 100 + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowRunListItem" + }, + "description": "Items in the current page." }, - "description": { - "description": "New release note for the deployment version, or null to clear it.", + "nextCursor": { "anyOf": [ { - "type": "string", - "maxLength": 50000 + "type": "string" }, { "type": "null" } - ] + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update workflow version request", - "description": "Merge-patch body for the mutable metadata of a deployment version.", + "title": "Workflow run list response", + "description": "A cursor-paginated page of workflow run summaries.", "examples": [ { - "name": "Escalation routing", - "description": "Adds the priority escalation branch." + "data": [ + { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "trigger": "api", + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000, + "cost": { + "total": 12 + } + } + ], + "nextCursor": null } ] }, - "ActiveDeploymentSummary": { - "type": "object", - "properties": { - "deploymentVersionId": { - "type": "string", - "description": "Identifier of the active deployment version." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Numeric active deployment version." - }, - "deployedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version became active.", - "format": "date-time" - } - }, - "required": ["deploymentVersionId", "version", "deployedAt"], - "additionalProperties": false, - "title": "Active deployment", - "description": "Summary of the workflow version currently serving API executions." - }, - "DeploymentOperationSummary": { + "V2RunFile": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique deployment operation identifier." + "description": "Identifier to address this file by on the download endpoint." }, - "deploymentVersionId": { + "name": { "type": "string", - "description": "Deployment version targeted by this operation." + "description": "File name, including its extension." }, - "version": { + "size": { "type": "integer", - "exclusiveMinimum": 0, + "minimum": 0, "maximum": 9007199254740991, - "description": "Numeric deployment version." - }, - "action": { - "type": "string", - "enum": ["deploy", "activate"], - "description": "Operation being performed on the deployment version." + "description": "File size in bytes." }, - "status": { + "type": { "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"], - "description": "Current deployment lifecycle status." - }, - "isCurrent": { - "default": true, - "description": "Whether this operation still describes the current deployment attempt.", - "type": "boolean" - }, - "readiness": { - "$ref": "#/components/schemas/DeploymentReadiness" + "description": "MIME type recorded for the file." }, - "requestedAt": { + "downloadPath": { "type": "string", - "description": "ISO 8601 timestamp when the deployment operation was requested.", - "format": "date-time" + "description": "Path to fetch this file's bytes from, relative to the API host." }, - "activatedAt": { - "description": "ISO 8601 activation timestamp, or null before activation completes.", - "format": "date-time", + "base64": { "anyOf": [ { "type": "string" @@ -7652,97 +11855,56 @@ { "type": "null" } - ] - }, - "error": { - "description": "Deployment failure details, or null when no failure occurred.", - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationError" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "deploymentVersionId", - "version", - "action", - "status", - "isCurrent", - "readiness", - "requestedAt" - ], - "additionalProperties": false, - "title": "Deployment operation", - "description": "Lifecycle state of a deployment or version-activation attempt." - }, - "DeploymentReadiness": { - "type": "object", - "properties": { - "webhooks": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Webhook synchronization readiness." - }, - "schedules": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Schedule synchronization readiness." - }, - "mcp": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "MCP synchronization readiness." + ], + "description": "Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null." } }, - "required": ["webhooks", "schedules", "mcp"], + "required": ["id", "name", "size", "type", "downloadPath", "base64"], "additionalProperties": false, - "title": "Deployment readiness", - "description": "Readiness of the side effects required to activate a deployment." + "title": "Workflow run file", + "description": "A file produced by a workflow run." }, - "DeploymentOperationError": { + "WorkflowRunStatus": { "type": "object", "properties": { - "code": { + "runId": { "type": "string", - "description": "Stable deployment failure code." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "message": { + "workflowId": { "type": "string", - "description": "Human-readable deployment failure message." + "description": "Workflow that produced the run." }, - "retryable": { - "type": "boolean", - "description": "Whether retrying the deployment may succeed." - } - }, - "required": ["code", "message", "retryable"], - "additionalProperties": false, - "title": "Deployment operation error", - "description": "Failure details for a deployment lifecycle operation." - }, - "VersionActivationResult": { - "title": "Version activation result", - "description": "Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.", - "$ref": "#/components/schemas/RollbackResult" - }, - "RollbackResult": { - "type": "object", - "properties": { - "id": { + "status": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "enum": [ + "pending", + "running", + "paused", + "redacting", + "completed", + "failed", + "cancelled", + "queued" + ], + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "trigger": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll." }, - "deployedAt": { + "startedAt": { "anyOf": [ { "type": "string" @@ -7751,1993 +11913,2664 @@ "type": "null" } ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.", + "format": "date-time" }, - "activeDeployment": { + "endedAt": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "type": "string" }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." + "description": "ISO 8601 end timestamp, or null while nonterminal.", + "format": "date-time" }, - "latestDeploymentAttempt": { + "durationMs": { "anyOf": [ { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "type": "number" }, { "type": "null" } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "description": "Run duration in milliseconds, or null while active." }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Deployment version selected for re-activation." - } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "version" - ], - "additionalProperties": false, - "title": "Rollback result", - "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." - }, - "ActivateWorkflowVersionResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" + "paused": { + "anyOf": [ + { + "type": "object", + "properties": { + "contextId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Resume context identifier, or null while every pause point is mid-resume." }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." - }, - "activeDeployment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "pausedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the execution entered the paused state." }, - { - "type": "null" - } - ], - "description": "Currently live deployment version, or null while no version is active." - }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "resumeAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 scheduled automatic-resume timestamp, or null when no resume time is set." }, - { - "type": "null" + "pauseKind": { + "anyOf": [ + { + "type": "string", + "enum": ["time", "human"] + }, + { + "type": "null" + } + ], + "description": "Whether the pause waits for time or human input, or null when unspecified." + }, + "blockedOnBlockId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow block awaiting resume, or null when no block is identified." + }, + "automaticResumeWaitingReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." + }, + "pausePointCount": { + "type": "number", + "description": "Number of pause points tracked for the execution." + }, + "resumedCount": { + "type": "number", + "description": "Number of pause points that have resumed." } + }, + "required": [ + "contextId", + "pausedAt", + "resumeAt", + "pauseKind", + "blockedOnBlockId", + "automaticResumeWaitingReason", + "pausePointCount", + "resumedCount" ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "additionalProperties": false }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Deployment version selected for re-activation." + { + "type": "null" } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "version" ], - "additionalProperties": false, - "description": "Response data.", - "$ref": "#/components/schemas/VersionActivationResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Activate workflow version response", - "description": "Current deployment state after accepting the activation attempt.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", - "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", - "version": 3, - "action": "activate", - "status": "activating", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 3 - } - } - ] - }, - "ActivateWorkflowVersionRequest": { - "default": {}, - "title": "Activate workflow version request", - "description": "No body. The version to promote is named by the request path.", - "examples": [{}], - "type": "object", - "properties": {}, - "additionalProperties": false - }, - "RevertWorkflowVersionResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier." + "description": "Current pause details, or null when the run is not paused." }, - "version": { + "cost": { "anyOf": [ { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total credits consumed by the run." + } + }, + "required": ["total"], + "additionalProperties": false }, { - "type": "string", - "const": "active" + "type": "null" } ], - "description": "Deployment version loaded into the draft, or `active` for the live version." - }, - "lastSaved": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Epoch milliseconds at which the overwritten draft was saved." - } - }, - "required": ["id", "version", "lastSaved"], - "additionalProperties": false, - "title": "Revert workflow version result", - "description": "The draft after it was overwritten by a deployment version." - }, - "RevertWorkflowVersionResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/RevertWorkflowVersionResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Revert workflow version response", - "description": "The draft after it was overwritten by the deployment version.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "version": 3, - "lastSaved": 1765535400000 - } - } - ] - }, - "RevertWorkflowVersionRequest": { - "default": {}, - "title": "Revert workflow version request", - "description": "No body. The version to load into the draft is named by the request path.", - "examples": [{}], - "type": "object", - "properties": {}, - "additionalProperties": false - }, - "WorkflowDeployment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "description": "Credit cost, or null when unavailable." }, - "deployedAt": { + "error": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ExecutionError" }, { "type": "null" } ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response." }, - "activeDeployment": { + "output": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "description": "Final workflow output value." }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." + "description": "Final workflow output when requested, otherwise null." }, - "latestDeploymentAttempt": { + "blockOutputs": { "anyOf": [ { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Output value produced by one workflow block." + } }, { "type": "null" } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." - }, - "needsRedeployment": { - "type": "boolean", - "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." + "description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only." }, - "isPublicApi": { - "type": "boolean", - "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." + "files": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2RunFile" + } + }, + { + "type": "null" + } + ], + "description": "Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`." } }, "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "needsRedeployment", - "isPublicApi" + "runId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "paused", + "cost", + "error", + "output", + "blockOutputs", + "files" ], "additionalProperties": false, - "title": "Workflow deployment", - "description": "Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt." + "title": "Workflow run status", + "description": "Detailed current state of a workflow run." }, - "WorkflowDeploymentResponse": { + "WorkflowRunStatusResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowDeployment" + "$ref": "#/components/schemas/WorkflowRunStatus" } }, "required": ["data"], "additionalProperties": false, - "title": "Workflow deployment response", - "description": "Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.", + "title": "Workflow run status response", + "description": "Detailed current state of a workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": true, - "needsRedeployment": true, - "isPublicApi": false, - "deployedAt": "2026-06-12T10:30:00.000Z", - "warnings": [], - "activeDeployment": { - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "deployedAt": "2026-06-12T10:30:00.000Z" + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "trigger": "api", + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000, + "paused": null, + "cost": { + "total": 12 }, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "action": "deploy", - "status": "active", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:29:58.000Z", - "activatedAt": "2026-06-12T10:30:00.000Z", - "error": null - } + "error": null, + "output": { + "result": "Ticket routed to Support" + }, + "blockOutputs": null, + "files": [ + { + "id": "file_1a2b3c", + "name": "summary.pdf", + "size": 20480, + "type": "application/pdf", + "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a/files/file_1a2b3c", + "base64": null + } + ] } } ] }, - "WorkflowPublicApiSettings": { + "ResumeWorkflowSyncResponse": { "type": "object", "properties": { - "id": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowRunResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Synchronous workflow resume response", + "description": "Completed, failed, paused, or cancelled resumed workflow run.", + "examples": [ + { + "data": { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "output": { + "result": "Ticket routed to Support" + }, + "error": null, + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000 + } + } + ] + }, + "QueuedWorkflowResume": { + "type": "object", + "properties": { + "runId": { "type": "string", - "description": "Unique workflow identifier." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "Absolute URL of the workflow run resource." }, - "isPublicApi": { - "type": "boolean", - "description": "Whether the deployed workflow accepts unauthenticated public API execution." + "queuePosition": { + "description": "Current queue position, when available.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, - "required": ["id", "isPublicApi"], + "required": ["runId", "statusUrl"], "additionalProperties": false, - "title": "Workflow public API settings", - "description": "Whether a deployed workflow is executable without an API key." + "title": "Queued workflow resume", + "description": "Receipt returned when a resumed workflow attempt is queued." }, - "UpdateWorkflowPublicApiResponse": { + "ResumeWorkflowQueuedResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowPublicApiSettings" + "$ref": "#/components/schemas/QueuedWorkflowResume" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow public API response", - "description": "Public API access after the update.", + "title": "Queued workflow resume response", + "description": "Receipt returned when a resumed workflow attempt is queued.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isPublicApi": true + "runId": "run_8f14e45f-ceea-467f-a", + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" } } ] }, - "UpdateWorkflowPublicApiRequest": { + "ResumeWorkflowRequest": { "type": "object", "properties": { - "isPublicApi": { - "type": "boolean", - "description": "Whether the deployed workflow should accept unauthenticated public API execution." + "contextId": { + "type": "string", + "minLength": 1, + "description": "Human-in-the-loop pause-context identifier." + }, + "input": { + "description": "Input supplied to the paused workflow block." } }, - "required": ["isPublicApi"], + "required": ["contextId"], "additionalProperties": false, - "title": "Update workflow public API request", - "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "title": "Resume workflow request", + "description": "Pause context and optional input used to resume a workflow run.", "examples": [ { - "isPublicApi": true + "contextId": "ctx_123", + "input": { + "approved": true + } } ] }, - "DeployResult": { + "CancelWorkflowRunResult": { "type": "object", "properties": { - "id": { + "success": { + "type": "boolean", + "description": "Whether cancellation was accepted." + }, + "runId": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "isDeployed": { + "redisAvailable": { "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] + "description": "Whether the distributed cancellation channel was available." }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "durablyRecorded": { + "type": "boolean", + "description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written." }, - "activeDeployment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ActiveDeploymentSummary" - }, - { - "type": "null" - } - ], - "description": "Currently live deployment version, or null while no version is active." + "locallyAborted": { + "type": "boolean", + "description": "Whether an in-process execution was aborted." }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" - }, - { - "type": "null" - } - ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "pausedCancelled": { + "type": "boolean", + "description": "Whether a paused execution was cancelled." }, - "version": { - "description": "Deployment version created for this attempt, when available.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "reason": { + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", + "type": "string", + "enum": [ + "recorded", + "already_cancelled", + "already_completed", + "already_failed", + "redis_unavailable", + "redis_write_failed", + "paused_event_publish_failed", + "paused_database_cancel_failed", + "queue_cancelled", + "active_resume_signal_failed", + "cancellation_not_finalized" + ] } }, "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt" + "success", + "runId", + "redisAvailable", + "durablyRecorded", + "locallyAborted", + "pausedCancelled" ], "additionalProperties": false, - "title": "Deploy result", - "description": "Deployment attempt accepted for asynchronous activation. `latestDeploymentAttempt` identifies the attempt. Poll Get Workflow Deployment for `isDeployed` and `deployedAt`, or List Workflow Versions for `isActive`." + "title": "Cancel workflow run result", + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." }, - "DeployWorkflowResponse": { + "CancelWorkflowRunResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeployResult" + "$ref": "#/components/schemas/CancelWorkflowRunResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Deploy workflow response", - "description": "Current deployment state after accepting the attempt.", + "title": "Cancel workflow run response", + "description": "Outcome of the cancellation request.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "action": "deploy", - "status": "preparing", - "isCurrent": true, - "readiness": { - "webhooks": "pending", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 3 + "success": true, + "runId": "run_8f14e45f-ceea-467f-a", + "redisAvailable": true, + "durablyRecorded": true, + "locallyAborted": true, + "pausedCancelled": false, + "reason": "recorded" } } ] }, - "DeployWorkflowRequest": { - "default": {}, - "title": "Deploy workflow request", - "description": "Optional metadata for the new deployment version.", - "examples": [ - { - "name": "Escalation routing", - "description": "Adds the priority escalation branch." - } - ], + "WorkflowFolder": { "type": "object", "properties": { "name": { - "description": "Optional label for the deployment version.", "type": "string", - "minLength": 1, - "maxLength": 100 + "description": "Folder name." }, - "description": { - "description": "Optional release note for the deployment version.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "UndeployResult": { - "type": "object", - "properties": { - "id": { + "path": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "parentPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was last updated.", + "format": "date-time" }, - "warnings": { + "locked": { + "type": "boolean", + "description": "Whether the folder is currently locked for mutation." + } + }, + "required": ["name", "path", "parentPath", "createdAt", "updatedAt", "locked"], + "additionalProperties": false, + "title": "Workflow folder", + "description": "A canonical workflow folder and its mutation lock state." + }, + "WorkflowFolderListResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/WorkflowFolder" }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "Items in the current page." }, - "activeDeployment": { + "nextCursor": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "type": "string" }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." - }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" - }, + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow folder list response", + "description": "A list of canonical workflow folders.", + "examples": [ + { + "data": [ { - "type": "null" + "name": "Operations", + "path": "/Operations", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "nextCursor": null } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt" - ], - "additionalProperties": false, - "title": "Undeploy result", - "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active." + ] }, - "UndeployWorkflowResponse": { + "CreateWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/UndeployResult" + "$ref": "#/components/schemas/WorkflowFolder" } }, "required": ["data"], "additionalProperties": false, - "title": "Undeploy workflow response", - "description": "Deployment state after deactivating the active version.", + "title": "Create workflow folder response", + "description": "The created workflow folder.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": null + "name": "Operations", + "path": "/Operations", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } } ] }, - "RollbackWorkflowResponse": { + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "CreateWorkflowFolderRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the folder." + }, + "path": { + "description": "Path of the folder to create.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + "required": ["workspaceId", "path"], + "additionalProperties": false, + "title": "Create workflow folder request", + "description": "Workspace and canonical path for a new workflow folder." + }, + "RelocateWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/RollbackResult" + "$ref": "#/components/schemas/WorkflowFolder" } }, "required": ["data"], "additionalProperties": false, - "title": "Rollback workflow response", - "description": "Current deployment state after accepting the rollback attempt.", + "title": "Relocate workflow folder response", + "description": "The relocated workflow folder.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", - "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", - "version": 2, - "action": "activate", - "status": "activating", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 2 + "name": "Support", + "path": "/Support", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } } ] }, - "RollbackWorkflowRequest": { - "default": {}, - "title": "Rollback workflow request", - "description": "Optional deployment version to reactivate.", - "examples": [ - { - "version": 2 - } - ], + "RelocateWorkflowFolderRequest": { "type": "object", "properties": { - "version": { - "description": "Deployment version to reactivate. Omit to select the previous active version.", - "type": "integer", - "minimum": 1, - "maximum": 2147483647 + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + }, + "path": { + "description": "Current folder path.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + }, + "destinationPath": { + "description": "New full path for the folder and its descendants.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, - "additionalProperties": false + "required": ["workspaceId", "path", "destinationPath"], + "additionalProperties": false, + "title": "Relocate workflow folder request", + "description": "Current and destination paths for a workflow folder." }, - "WorkflowExportPayload": { + "DeleteWorkflowFolderResult": { "type": "object", "properties": { - "version": { + "path": { "type": "string", - "const": "1.0", - "description": "Workflow export format version." + "title": "Folder path", + "description": "Path of the deleted workflow folder.", + "maxLength": 4096 }, - "exportedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the export was created.", - "format": "date-time" + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the folder was deleted." }, - "workflow": { + "deletedItems": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Identifier of the source workflow." - }, - "name": { - "type": "string", - "description": "Name of the exported workflow." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Description of the exported workflow, or null when unset." - }, - "workspaceId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Identifier of the source workspace, or null for legacy exports." + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of folders deleted." }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096 + "workflows": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of workflows deleted." } }, - "required": ["id", "name", "description", "workspaceId", "folderPath"], + "required": ["folders", "workflows"], "additionalProperties": false, - "description": "Source workflow metadata." - }, - "state": { - "type": "object", - "additionalProperties": true, - "description": "Secret-sanitized workflow graph, edges, loops, parallels, metadata, and variables." + "description": "Resources removed by the deletion." } }, - "required": ["version", "exportedAt", "workflow", "state"], + "required": ["path", "deleted", "deletedItems"], "additionalProperties": false, - "title": "Workflow export payload", - "description": "Portable, secret-sanitized workflow export. Workspace-scoped bindings must be selected again after import." + "title": "Delete workflow folder result", + "description": "Confirmation and deletion counts for a workflow folder." }, - "ExportWorkflowResponse": { + "DeleteWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowExportPayload" + "$ref": "#/components/schemas/DeleteWorkflowFolderResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Export workflow response", - "description": "Portable, secret-sanitized workflow data.", + "title": "Delete workflow folder response", + "description": "Confirmation and counts for the deleted folder.", "examples": [ { "data": { - "version": "1.0", - "exportedAt": "2026-08-09T18:04:11.000Z", - "workflow": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderPath": "/Operations" - }, - "state": { - "blocks": {}, - "edges": [] + "path": "/Operations", + "deleted": true, + "deletedItems": { + "folders": 1, + "workflows": 0 } } } ] }, - "ImportedWorkflow": { + "WorkspaceForkPreview": { "type": "object", "properties": { - "id": { + "previewFingerprint": { "type": "string", - "description": "Identifier of the imported workflow." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "name": { + "sourceWorkspaceId": { "type": "string", - "description": "Imported workflow name." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace the workflows and resources are copied from." }, - "description": { - "anyOf": [ - { - "type": "string" + "workflows": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } }, - { - "type": "null" - } - ], - "description": "Imported workflow description." - }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the imported workflow." - }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path.", - "maxLength": 4096 + "required": ["sourceWorkflowId", "name"], + "additionalProperties": false + }, + "description": "Eligible workflows and their planned actions." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was imported.", - "format": "date-time" + "selectedResourceCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources explicitly selected for copying." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "draftOnly": { + "type": "boolean", + "const": true, + "description": "True because fork creation produces undeployed drafts." } }, "required": [ - "id", - "name", - "description", - "workspaceId", - "folderPath", - "createdAt", - "updatedAt" + "previewFingerprint", + "sourceWorkspaceId", + "workflows", + "selectedResourceCount", + "draftOnly" ], "additionalProperties": false, - "title": "Imported workflow", - "description": "Workflow created by an import operation." + "title": "WorkspaceForkPreview", + "description": "The WorkspaceForkPreview result." }, - "ImportWorkflowResponse": { + "PreviewWorkspaceForkResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ImportedWorkflow" + "$ref": "#/components/schemas/WorkspaceForkPreview" } }, "required": ["data"], "additionalProperties": false, - "title": "Import workflow response", - "description": "The workflow created by the import.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderPath": "/Operations", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - } - ] + "title": "PreviewWorkspaceFork response", + "description": "The response for this operation." }, - "ImportWorkflowRequest": { + "PreviewWorkspaceForkBody": { "type": "object", "properties": { - "workspaceId": { + "name": { + "description": "Display name of the workflow or workspace.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to import the workflow." + "maxLength": 100 }, - "workflow": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "description": "JSON string containing a workflow export object or bare workflow state." + "copy": { + "description": "Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.", + "type": "object", + "properties": { + "files": { + "description": "Workspace file IDs to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } }, - { - "type": "object", - "additionalProperties": true, - "description": "Workflow export object or bare workflow state." + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "workflowMcpServers": { + "description": "Workflow-publishing MCP server identifiers to copy as empty configuration shells.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } - ], - "description": "Workflow export object, bare workflow state, or JSON string containing either form." - }, - "folderPath": { - "description": "Destination folder path; omit for the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - }, - "name": { - "description": "Override for the imported workflow name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "description": "Override for the imported workflow description.", - "type": "string", - "maxLength": 2000 - } - }, - "required": ["workspaceId", "workflow"], - "additionalProperties": false, - "title": "Import workflow request", - "description": "Portable workflow data and destination metadata for an import." - }, - "ChatDeploymentListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique chat deployment identifier." - }, - "workflowId": { - "type": "string", - "description": "Workflow this deployment publishes." - }, - "workspaceId": { - "type": "string", - "description": "Workspace the deployment belongs to, derived from its workflow." - }, - "identifier": { - "type": "string", - "description": "URL slug the deployed chat answers on. Unique across live deployments." - }, - "url": { - "type": "string", - "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", - "examples": ["https://sim.ai/chat/support"] - }, - "title": { - "type": "string", - "description": "Title shown to visitors." - }, - "description": { - "type": "string", - "description": "Description shown to visitors. Empty when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether the deployment answers requests." - }, - "authType": { - "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." - }, - "outputConfigs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "description": "Block outputs surfaced to visitors." - }, - "includeThinking": { - "type": "boolean", - "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." - }, - "includeToolCalls": { - "type": "boolean", - "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "workflowId", - "workspaceId", - "identifier", - "url", - "title", - "description", - "isActive", - "authType", - "outputConfigs", - "includeThinking", - "includeToolCalls", - "createdAt", - "updatedAt" - ], + "additionalProperties": false + } + }, "additionalProperties": false, - "title": "Chat deployment list entry", - "description": "A workflow published as a hosted chat, without the fields the detail read gates." + "title": "PreviewWorkspaceFork body", + "description": "The body for this operation." }, - "StoredChatDeploymentOutputConfig": { + "WorkspaceOperationReport": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", - "type": "string" + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Durable operation identifier to use for polling." }, - "blockId": { + "requestId": { "type": "string", - "description": "Block whose output the chat streams." + "minLength": 1, + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." }, - "path": { + "workspaceId": { "type": "string", - "description": "Path within that block output. Empty means the whole output." - } - }, - "required": ["blockId", "path"], - "additionalProperties": false, - "title": "Stored chat deployment output config", - "description": "One block output currently surfaced to chat visitors." - }, - "ChatDeploymentListResponse": { - "type": "object", - "properties": { - "data": { + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + }, + "kind": { + "type": "string", + "enum": ["workflow_import", "workspace_fork", "workspace_push", "workspace_pull"], + "description": "Resource or operation kind." + }, + "applied": { + "type": "boolean", + "const": true, + "description": "The business transaction committed, including when follow-up work fails." + }, + "status": { + "type": "string", + "enum": [ + "processing", + "completed", + "completed_with_warnings", + "requires_configuration", + "failed" + ], + "description": "Current operation or deployment outcome." + }, + "resourceIds": { + "maxItems": 5000, "type": "array", "items": { - "$ref": "#/components/schemas/ChatDeploymentListItem" + "type": "string", + "minLength": 1, + "maxLength": 256 }, - "description": "Items in the current page." + "description": "Identifiers of resources created or changed by the committed operation." }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "issues": { + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable machine-readable issue code." + }, + "message": { + "type": "string", + "maxLength": 2048, + "description": "Human-readable explanation of the issue." + }, + "workflowId": { + "description": "Workflow affected by this issue or deployment attempt.", + "type": "string", + "maxLength": 256 + }, + "blockId": { + "description": "Source block identifier before graph ID regeneration.", + "type": "string", + "maxLength": 256 + }, + "subBlockKey": { + "description": "Registered source field key, including the tool index for nested Agent fields.", + "type": "string", + "maxLength": 256 + } }, - { - "type": "null" + "required": ["code", "message"], + "additionalProperties": false + }, + "description": "Structured warnings, missing configuration, and follow-up failures." + }, + "idMap": { + "description": "Source graph identifiers mapped to the imported identifiers.", + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "deploymentOperationIds": { + "description": "Exact deployment attempts admitted by the workspace operation.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + }, + "deployments": { + "description": "Readiness of the exact admitted deployment attempts.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + }, + "workflowId": { + "type": "string", + "maxLength": 256, + "description": "Workflow affected by this issue or deployment attempt." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Reference format or deployment version number." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current operation or deployment outcome." + }, + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "pendingComponents": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + }, + "description": "Deployment components that have not finished becoming ready." + } + }, + "required": [ + "operationId", + "workflowId", + "version", + "status", + "ready", + "pendingComponents" + ], + "additionalProperties": false + } + }, + "triggerUrlChanges": { + "description": "Public trigger paths changed by this sync, with the affected workflow names.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the workflow whose public trigger path stops serving." + }, + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } + }, + "required": ["workflowName", "path"], + "additionalProperties": false + } + }, + "backgroundWorkId": { + "description": "Workspace activity identifier for resource-copy progress.", + "type": "string", + "maxLength": 256 + }, + "copyProgress": { + "description": "Completion status and counts for explicitly selected resource copies.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending", "completed", "failed"], + "description": "Current operation or deployment outcome." + }, + "copied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources copied successfully." + }, + "failed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources that failed to copy." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "required": ["status", "copied", "failed"], + "additionalProperties": false } }, - "required": ["data", "nextCursor"], + "required": [ + "operationId", + "requestId", + "workspaceId", + "kind", + "applied", + "status", + "resourceIds", + "issues" + ], "additionalProperties": false, - "title": "Chat deployment list response", - "description": "A cursor-paginated page of chat deployments.", - "examples": [ - { - "data": [ - { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" - } - ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - ], - "nextCursor": null - } - ] + "title": "WorkspaceOperationReport", + "description": "The WorkspaceOperationReport result." }, - "StoredChatDeploymentCustomizations": { + "ForkWorkspaceResponse": { "type": "object", "properties": { - "primaryColor": { - "description": "CSS color used for the chat accent.", - "type": "string" - }, - "welcomeMessage": { - "description": "First message shown to a visitor.", - "type": "string" - }, - "imageUrl": { - "description": "Avatar image shown beside assistant messages.", - "type": "string" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, + "required": ["data"], "additionalProperties": false, - "title": "Stored chat deployment customizations", - "description": "Presentation overrides currently stored on the deployed chat." + "title": "ForkWorkspace response", + "description": "The response for this operation." }, - "ChatDeployment": { + "ForkWorkspaceBody": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique chat deployment identifier." - }, - "workflowId": { + "name": { + "description": "Display name of the workflow or workspace.", "type": "string", - "description": "Workflow this deployment publishes." + "minLength": 1, + "maxLength": 100 }, - "workspaceId": { - "type": "string", - "description": "Workspace the deployment belongs to, derived from its workflow." + "copy": { + "description": "Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.", + "type": "object", + "properties": { + "files": { + "description": "Workspace file IDs to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "workflowMcpServers": { + "description": "Workflow-publishing MCP server identifiers to copy as empty configuration shells.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "identifier": { + "requestId": { "type": "string", - "description": "URL slug the deployed chat answers on. Unique across live deployments." + "minLength": 1, + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." }, - "url": { + "previewFingerprint": { "type": "string", - "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", - "examples": ["https://sim.ai/chat/support"] - }, - "title": { + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." + } + }, + "required": ["requestId", "previewFingerprint"], + "additionalProperties": false, + "title": "ForkWorkspace body", + "description": "The body for this operation." + }, + "WorkspaceSyncPreview": { + "type": "object", + "properties": { + "previewFingerprint": { "type": "string", - "description": "Title shown to visitors." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "description": { + "sourceWorkspaceId": { "type": "string", - "description": "Description shown to visitors. Empty when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether the deployment answers requests." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace the workflows and resources are copied from." }, - "authType": { + "targetWorkspaceId": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace receiving the changes." }, - "hasPassword": { + "ready": { "type": "boolean", - "description": "Whether a password is stored. The password itself is never readable." + "description": "Whether the operation passes its current apply or deployment readiness checks." }, - "allowedEmails": { + "workflows": { + "maxItems": 2000, "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "replace", "archive"], + "description": "Planned workflow creation, replacement, or archival." + }, + "sourceWorkflowId": { + "description": "Workflow identifier in the source workspace.", + "type": "string", + "minLength": 1 + }, + "targetWorkflowId": { + "description": "Existing target workflow identifier; absent when apply will create a new target.", + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } + }, + "required": ["action", "name"], + "additionalProperties": false }, - "description": "Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise." - }, - "customizations": { - "description": "Presentation overrides. Unset fields fall back to platform defaults.", - "$ref": "#/components/schemas/StoredChatDeploymentCustomizations" + "description": "Eligible workflows and their planned actions." }, - "outputConfigs": { + "unresolvedBindings": { + "maxItems": 10000, "type": "array", "items": { - "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" + "type": "object", + "properties": { + "kind": { + "type": "string", + "maxLength": 256, + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "blockName": { + "description": "Display name of the affected source block.", + "type": "string", + "maxLength": 1024 + }, + "reason": { + "description": "Structured explanation of the unresolved binding.", + "type": "string", + "maxLength": 256 + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false }, - "description": "Block outputs surfaced to visitors." - }, - "includeThinking": { - "type": "boolean", - "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." - }, - "includeToolCalls": { - "type": "boolean", - "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was created.", - "format": "date-time" + "description": "Source references that still require destination mappings or explicit copy choices." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "workflowId", - "workspaceId", - "identifier", - "url", - "title", - "description", - "isActive", - "authType", - "hasPassword", - "allowedEmails", - "customizations", - "outputConfigs", - "includeThinking", - "includeToolCalls", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Chat deployment", - "description": "A workflow published as a hosted chat." - }, - "GetWorkflowChatDeploymentResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ChatDeployment" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workflow chat deployment response", - "description": "The workflow's chat deployment.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "hasPassword": false, - "allowedEmails": [], - "customizations": { - "primaryColor": "#6F3DFA", - "welcomeMessage": "Hi there! How can I help?" - }, - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" + "configuration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "currentValue": { + "type": "string", + "maxLength": 65536, + "description": "Persisted sync override or proposed override; empty when neither is configured." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s options.", + "type": "string", + "maxLength": 256 + }, + "discoveryWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace scope for selector discovery: source for a parent being copied, otherwise destination." + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + }, + "description": "Allowlisted selector dependencies scoped to discoveryWorkspaceId." + }, + "parentKind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource kind that owns this dependent configuration." + }, + "parentSourceId": { + "type": "string", + "maxLength": 4096, + "description": "Source identifier of the parent resource being mapped." + }, + "parentContextKey": { + "description": "Selector context key supplied by the mapped parent resource.", + "type": "string", + "maxLength": 256 } + }, + "required": [ + "sourceWorkflowId", + "sourceBlockId", + "subBlockKey", + "title", + "required", + "currentValue", + "discoveryWorkspaceId", + "context", + "parentKind", + "parentSourceId" ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - } - ] - }, - "ReplaceWorkflowChatDeploymentResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ChatDeployment" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Replace workflow chat deployment response", - "description": "The chat deployment as stored after the replace.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "hasPassword": false, - "allowedEmails": [], - "customizations": { - "primaryColor": "#6F3DFA", - "welcomeMessage": "Hi there! How can I help?" + "additionalProperties": false + }, + "description": "Dependent fields that may need destination-specific values." + }, + "excludedTargets": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Resource identifier." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } }, - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" + "required": ["id", "name"], + "additionalProperties": false + }, + "description": "Target workflows explicitly excluded from sync." + }, + "triggerSlots": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Stable source trigger block identifier to use in trigger mappings." + }, + "blockName": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the source trigger block." + }, + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the source workflow." + }, + "ownPath": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Existing target trigger path, preserved automatically and not configurable." + }, + "adoptablePaths": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "description": "Retiring paths in the same target workflow with a compatible trigger provider." + }, + "defaultAdoptPath": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Default adoption when ownPath is null; null then allocates a new path." } + }, + "required": [ + "sourceWorkflowId", + "sourceBlockId", + "blockName", + "workflowName", + "ownPath", + "adoptablePaths", + "defaultAdoptPath" ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - } - ] - }, - "ChatDeploymentCustomizations": { - "type": "object", - "properties": { - "primaryColor": { - "description": "CSS color used for the chat accent.", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "welcomeMessage": { - "description": "First message shown to a visitor.", - "type": "string", - "maxLength": 2000 + "additionalProperties": false + }, + "description": "Source triggers and the target paths available for explicit adoption choices." }, - "imageUrl": { - "description": "Avatar image shown beside assistant messages.", - "type": "string", - "maxLength": 2048 + "triggerUrlChanges": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the affected workflow." + }, + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } + }, + "required": ["workflowName", "path"], + "additionalProperties": false + }, + "description": "Retiring target trigger URLs no arriving trigger adopts." } }, + "required": [ + "previewFingerprint", + "sourceWorkspaceId", + "targetWorkspaceId", + "ready", + "workflows", + "unresolvedBindings", + "configuration", + "excludedTargets", + "triggerSlots", + "triggerUrlChanges" + ], "additionalProperties": false, - "title": "Chat deployment customizations", - "description": "Presentation overrides for the deployed chat." + "title": "WorkspaceSyncPreview", + "description": "The WorkspaceSyncPreview result." }, - "ChatDeploymentOutputConfig": { + "PreviewWorkspacePushResponse": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omit for the deployed workflow.", - "type": "string", - "minLength": 1 - }, - "blockId": { - "type": "string", - "minLength": 1, - "description": "Block whose output the chat streams." - }, - "path": { - "type": "string", - "minLength": 1, - "description": "Path within that block output." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceSyncPreview" } }, - "required": ["blockId", "path"], + "required": ["data"], "additionalProperties": false, - "title": "Chat deployment output config", - "description": "One block output surfaced to chat visitors." + "title": "PreviewWorkspacePush response", + "description": "The response for this operation." }, - "ReplaceChatDeploymentRequest": { + "PreviewWorkspacePushBody": { "type": "object", "properties": { - "identifier": { + "otherWorkspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[a-z0-9-]+$", - "description": "URL slug the deployed chat answers on. Must be free across live deployments." - }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Title shown to visitors." - }, - "description": { - "description": "Description shown to visitors. Omitted clears it.", - "type": "string", - "maxLength": 2000 - }, - "customizations": { - "description": "Presentation overrides. Omitted fields take platform defaults.", - "$ref": "#/components/schemas/ChatDeploymentCustomizations" - }, - "authType": { - "description": "How visitors are gated. `public` leaves the chat open to anyone holding the URL.", - "default": "public", - "type": "string", - "enum": ["public", "password", "email", "sso"] - }, - "password": { - "description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.", - "type": "string", - "minLength": 1, - "maxLength": 1024 + "description": "Workspace on the other side of the direct fork edge." }, - "allowedEmails": { - "description": "Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.", - "maxItems": 500, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, "type": "array", "items": { - "type": "string", - "minLength": 1 + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false } }, - "outputConfigs": { - "description": "Block outputs to surface to visitors. Omitted surfaces none.", - "maxItems": 100, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, "type": "array", "items": { - "$ref": "#/components/schemas/ChatDeploymentOutputConfig" + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false } }, - "includeThinking": { - "description": "Allow visitors to receive provider thinking events.", - "default": false, - "type": "boolean" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "includeToolCalls": { - "description": "Allow visitors to receive tool lifecycle events.", - "default": false, - "type": "boolean" - } - }, - "required": ["identifier", "title"], - "additionalProperties": false, - "title": "Replace chat deployment request", - "description": "The complete desired state of a workflow's chat.", - "examples": [ - { - "identifier": "support", - "title": "Support chat" - } - ] - }, - "DeleteChatDeploymentResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the removed chat deployment." + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the deployment was removed." + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false + } } }, - "required": ["id", "deleted"], + "required": ["otherWorkspaceId"], "additionalProperties": false, - "title": "Delete chat deployment result", - "description": "Chat deployment removal acknowledgement." + "title": "PreviewWorkspacePush body", + "description": "The body for this operation." }, - "DeleteWorkflowChatDeploymentResponse": { + "PushWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteChatDeploymentResult" + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow chat deployment response", - "description": "Acknowledgement that the chat deployment was removed.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "deleted": true - } - } - ] - }, - "ExecutionError": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Human-readable workflow execution failure message." - }, - "code": { - "type": "string", - "enum": [ - "TIMEOUT", - "CANCELLED", - "USAGE_LIMIT_EXCEEDED", - "INVALID_INPUT", - "BLOCK_EXECUTION_FAILED", - "CHILD_WORKFLOW_FAILED", - "EXECUTION_FAILED" - ], - "description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`." - }, - "blockId": { - "description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.", - "type": "string" - }, - "blockName": { - "description": "Display name of the failing block. Present on the synchronous execute response only.", - "type": "string" - }, - "blockType": { - "description": "Integration or block type that failed. Present on the synchronous execute response only.", - "type": "string" - } - }, - "required": ["message", "code"], - "additionalProperties": false, - "title": "Execution error", - "description": "Structured in-band failure details for a workflow run." + "title": "PushWorkspace response", + "description": "The response for this operation." }, - "WorkflowRunResult": { + "PushWorkspaceBody": { "type": "object", "properties": { - "runId": { + "otherWorkspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." + "description": "Workspace on the other side of the direct fork edge." }, - "status": { - "type": "string", - "enum": ["completed", "failed", "paused", "cancelled"], - "description": "Terminal or paused run status." + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + } }, - "output": { - "description": "Workflow output, including partial output on failure." + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ExecutionError" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } }, - { - "type": "null" + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } - ], - "description": "Structured execution failure, or null when none occurred." - }, - "startedAt": { - "description": "ISO 8601 timestamp when execution started.", - "format": "date-time", - "type": "string" + }, + "additionalProperties": false }, - "endedAt": { - "description": "ISO 8601 timestamp when execution ended.", - "format": "date-time", - "type": "string" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "durationMs": { - "description": "Execution duration in milliseconds.", - "type": "number", - "minimum": 0 - } - }, - "required": ["runId", "workflowId", "status", "output", "error"], - "additionalProperties": false, - "title": "Workflow run result", - "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." - }, - "ExecuteWorkflowSyncResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Synchronous workflow execution response", - "description": "Completed, failed, paused, or cancelled synchronous workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "output": { - "result": "Ticket routed to Support" + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } }, - "error": null, - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false } - } - ] - }, - "QueuedWorkflowRun": { - "type": "object", - "properties": { - "runId": { + }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Stable client request ID for reconciliation and identical retries." }, - "statusUrl": { + "previewFingerprint": { "type": "string", - "format": "uri", - "description": "Absolute URL of the workflow run resource." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." + }, + "confirm": { + "type": "boolean", + "const": true, + "description": "Explicit acknowledgement that sync replaces target workflows." } }, - "required": ["runId", "statusUrl"], + "required": ["otherWorkspaceId", "requestId", "previewFingerprint", "confirm"], "additionalProperties": false, - "title": "Queued workflow run", - "description": "Receipt returned when a workflow run is queued." + "title": "PushWorkspace body", + "description": "The body for this operation." }, - "ExecuteWorkflowQueuedResponse": { + "PreviewWorkspacePullResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/QueuedWorkflowRun" + "$ref": "#/components/schemas/WorkspaceSyncPreview" } }, "required": ["data"], "additionalProperties": false, - "title": "Queued workflow execution response", - "description": "Receipt returned for an asynchronous workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" - } - } - ] + "title": "PreviewWorkspacePull response", + "description": "The response for this operation." }, - "ExecuteWorkflowRequest": { + "PreviewWorkspacePullBody": { "type": "object", "properties": { - "input": { - "description": "Workflow input keyed by the selected trigger input-field name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Value supplied for one workflow input field." - } + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." }, - "run": { - "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", - "oneOf": [ - { - "type": "object", - "properties": { - "source": { - "type": "string", - "const": "deployment", - "description": "Execute the active deployed workflow state." - } + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." }, - "required": ["source"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "source": { - "type": "string", - "const": "manual", - "description": "Execute the current saved workflow state manually." - }, - "entry": { - "description": "Manual entry mode. Omit to enter through the workflow trigger; a block entry requires an exact source run.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "trigger", - "description": "Enter the manual run through a runnable trigger." - }, - "blockId": { - "description": "Runnable trigger block to enter through. Omit only when the saved workflow has exactly one runnable trigger.", - "type": "string", - "minLength": 1 - }, - "useMockPayload": { - "description": "Use the selected trigger's server-derived mock payload. Cannot be combined with `input`.", - "type": "boolean" - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "block", - "description": "Resume manual execution at a block using persisted upstream state." - }, - "blockId": { - "type": "string", - "minLength": 1, - "description": "Saved workflow block at which manual execution should resume." - }, - "sourceRunId": { - "type": "string", - "minLength": 1, - "description": "Run ID supplying upstream block results when starting from a selected block." - } - }, - "required": ["type", "blockId", "sourceRunId"], - "additionalProperties": false - } - ] - } + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." }, - "required": ["source"], - "additionalProperties": false - } - ] - }, - "async": { - "default": false, - "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", - "type": "boolean" - }, - "executionTimeoutSeconds": { - "description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.", - "type": "integer", - "minimum": 1, - "maximum": 604800 - }, - "stream": { - "default": false, - "description": "Return Server-Sent Events instead of JSON when true. Cannot be combined with `async`.", - "type": "boolean" + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + } }, - "selectedOutputs": { - "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", - "maxItems": 100, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, "type": "array", "items": { - "type": "string", - "minLength": 1 + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false } }, - "includeThinking": { - "default": false, - "description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", - "type": "boolean" - }, - "includeToolCalls": { - "default": false, - "description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", - "type": "boolean" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "includeFileBase64": { - "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", - "type": "boolean" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "base64MaxBytes": { - "description": "Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 16777216 + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false + } + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "PreviewWorkspacePull body", + "description": "The body for this operation." + }, + "PullWorkspaceResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, + "required": ["data"], "additionalProperties": false, - "title": "Execute workflow request", - "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", - "examples": [ - { - "input": { - "ticketId": "ticket_123" + "title": "PullWorkspace response", + "description": "The response for this operation." + }, + "PullWorkspaceBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + }, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false } }, - { - "input": { - "ticketId": "ticket_123" - }, - "async": true + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - { - "input": { - "ticketId": "ticket_123" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } }, - "stream": true + "additionalProperties": false }, - { - "run": { - "source": "manual" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false } }, - { - "run": { - "source": "manual", - "entry": { - "type": "block", - "blockId": "block_123", - "sourceRunId": "run_123" - } + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false } - } - ] - }, - "WorkflowRunListItem": { - "type": "object", - "properties": { - "runId": { + }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Stable client request ID for reconciliation and identical retries." }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." - }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "paused", - "redacting", - "completed", - "failed", - "cancelled" - ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." - }, - "trigger": { - "type": "string", - "description": "Trigger type that started the run." - }, - "startedAt": { + "previewFingerprint": { "type": "string", - "description": "ISO 8601 timestamp when the run started.", - "format": "date-time" + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "endedAt": { - "anyOf": [ - { - "type": "string" + "confirm": { + "type": "boolean", + "const": true, + "description": "Explicit acknowledgement that sync replaces target workflows." + } + }, + "required": ["otherWorkspaceId", "requestId", "previewFingerprint", "confirm"], + "additionalProperties": false, + "title": "PullWorkspace body", + "description": "The body for this operation." + }, + "GetWorkspaceForkAvailabilityResult": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "description": "Whether this deployment and workspace plan enable forking." + } + }, + "required": ["available"], + "additionalProperties": false, + "title": "GetWorkspaceForkAvailabilityResult", + "description": "The GetWorkspaceForkAvailabilityResult result." + }, + "GetWorkspaceForkAvailabilityResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/GetWorkspaceForkAvailabilityResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "GetWorkspaceForkAvailability response", + "description": "The response for this operation." + }, + "GetWorkspaceForkLineageResult": { + "type": "object", + "properties": { + "current": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when the run ended, or null while active.", - "format": "date-time" - }, - "durationMs": { - "anyOf": [ - { - "type": "number" + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." }, - { - "type": "null" + "organizationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Owning organization, or null for a personal workspace." } - ], - "description": "Run duration in milliseconds, or null while active." + }, + "required": ["id", "name", "organizationId"], + "additionalProperties": false, + "description": "The current workspace lineage node." }, - "cost": { + "parent": { "anyOf": [ { "type": "object", "properties": { - "total": { - "type": "number", - "description": "Total credits consumed by the run." + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + }, + "organizationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Owning organization, or null for a personal workspace." } }, - "required": ["total"], + "required": ["id", "name", "organizationId"], "additionalProperties": false }, { "type": "null" } ], - "description": "Credit cost, or null when unavailable." + "description": "The live parent workspace, or null when this workspace is not a fork." } }, - "required": [ - "runId", - "workflowId", - "status", - "trigger", - "startedAt", - "endedAt", - "durationMs", - "cost" - ], + "required": ["current", "parent"], "additionalProperties": false, - "title": "Workflow run summary", - "description": "Summary of a recorded workflow run." + "title": "GetWorkspaceForkLineageResult", + "description": "The GetWorkspaceForkLineageResult result." }, - "WorkflowRunListResponse": { + "GetWorkspaceForkLineageResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowRunListItem" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/GetWorkspaceForkLineageResult" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "Workflow run list response", - "description": "A cursor-paginated page of workflow run summaries.", - "examples": [ - { - "data": [ - { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "trigger": "api", - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000, - "cost": { - "total": 12 - } - } - ], - "nextCursor": null - } - ] + "title": "GetWorkspaceForkLineage response", + "description": "The response for this operation." }, - "V2RunFile": { + "ListWorkspaceForkChildrenResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier to address this file by on the download endpoint." + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." }, "name": { "type": "string", - "description": "File name, including its extension." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "File size in bytes." - }, - "type": { - "type": "string", - "description": "MIME type recorded for the file." - }, - "downloadPath": { - "type": "string", - "description": "Path to fetch this file's bytes from, relative to the API host." + "maxLength": 1024, + "description": "Display name of the workflow or workspace." }, - "base64": { + "organizationId": { "anyOf": [ { "type": "string" @@ -9746,755 +14579,1502 @@ "type": "null" } ], - "description": "Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null." + "description": "Owning organization, or null for a personal workspace." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 creation timestamp." } }, - "required": ["id", "name", "size", "type", "downloadPath", "base64"], + "required": ["id", "name", "organizationId", "createdAt"], "additionalProperties": false, - "title": "Workflow run file", - "description": "A file produced by a workflow run." + "title": "ListWorkspaceForkChildrenResult", + "description": "The ListWorkspaceForkChildrenResult result." }, - "WorkflowRunStatus": { + "ListWorkspaceForkChildrenResponse": { "type": "object", "properties": { - "runId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." - }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "paused", - "redacting", - "completed", - "failed", - "cancelled", - "queued" - ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." - }, - "trigger": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll." - }, - "startedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.", - "format": "date-time" - }, - "endedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 end timestamp, or null while nonterminal.", - "format": "date-time" - }, - "durationMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "description": "Run duration in milliseconds, or null while active." - }, - "paused": { - "anyOf": [ - { - "type": "object", - "properties": { - "contextId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Resume context identifier, or null while every pause point is mid-resume." - }, - "pausedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the execution entered the paused state." - }, - "resumeAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 scheduled automatic-resume timestamp, or null when no resume time is set." - }, - "pauseKind": { - "anyOf": [ - { - "type": "string", - "enum": ["time", "human"] - }, - { - "type": "null" - } - ], - "description": "Whether the pause waits for time or human input, or null when unspecified." - }, - "blockedOnBlockId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow block awaiting resume, or null when no block is identified." - }, - "automaticResumeWaitingReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." - }, - "pausePointCount": { - "type": "number", - "description": "Number of pause points tracked for the execution." - }, - "resumedCount": { - "type": "number", - "description": "Number of pause points that have resumed." - } - }, - "required": [ - "contextId", - "pausedAt", - "resumeAt", - "pauseKind", - "blockedOnBlockId", - "automaticResumeWaitingReason", - "pausePointCount", - "resumedCount" - ], - "additionalProperties": false + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListWorkspaceForkChildrenResult" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, { "type": "null" } ], - "description": "Current pause details, or null when the run is not paused." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceForkChildren response", + "description": "The response for this operation." + }, + "ListWorkspaceForkResourcesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 4096, + "description": "Resource identifier." }, - "cost": { + "label": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable resource label." + }, + "folderId": { + "description": "Containing folder identifier, or null at the workspace root.", "anyOf": [ { - "type": "object", - "properties": { - "total": { - "type": "number", - "description": "Total credits consumed by the run." - } - }, - "required": ["total"], - "additionalProperties": false + "type": "string" }, { "type": "null" } - ], - "description": "Credit cost, or null when unavailable." + ] }, - "error": { + "folderName": { + "description": "Containing folder name, or null at the workspace root.", "anyOf": [ { - "$ref": "#/components/schemas/ExecutionError" + "type": "string" }, { "type": "null" } - ], - "description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response." + ] + } + }, + "required": ["id", "label"], + "additionalProperties": false, + "title": "ListWorkspaceForkResourcesResult", + "description": "The ListWorkspaceForkResourcesResult result." + }, + "ListWorkspaceForkResourcesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListWorkspaceForkResourcesResult" + }, + "description": "Items in the current page." }, - "output": { + "nextCursor": { "anyOf": [ { - "description": "Final workflow output value." + "type": "string" }, { "type": "null" } ], - "description": "Final workflow output when requested, otherwise null." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceForkResources response", + "description": "The response for this operation." + }, + "GetWorkspaceForkMappingsResult": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." }, - "blockOutputs": { + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { "anyOf": [ { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Output value produced by one workflow block." - } + "type": "string", + "minLength": 1, + "maxLength": 4096 }, { "type": "null" } ], - "description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only." + "description": "Authorized destination identifier, or null to clear the mapping." }, - "files": { + "id": { + "type": "string", + "maxLength": 256, + "description": "Resource identifier." + } + }, + "required": ["resourceType", "sourceId", "targetId", "id"], + "additionalProperties": false, + "title": "GetWorkspaceForkMappingsResult", + "description": "The GetWorkspaceForkMappingsResult result." + }, + "GetWorkspaceForkMappingsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetWorkspaceForkMappingsResult" + }, + "description": "Items in the current page." + }, + "nextCursor": { "anyOf": [ { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2RunFile" - } + "type": "string" }, { "type": "null" } ], - "description": "Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": [ - "runId", - "workflowId", - "status", - "trigger", - "startedAt", - "endedAt", - "durationMs", - "paused", - "cost", - "error", - "output", - "blockOutputs", - "files" - ], + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "GetWorkspaceForkMappings response", + "description": "The response for this operation." + }, + "UpdateWorkspaceForkMappingsResult": { + "type": "object", + "properties": { + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of records changed." + } + }, + "required": ["updated"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappingsResult", + "description": "The UpdateWorkspaceForkMappingsResult result." + }, + "UpdateWorkspaceForkMappingsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappings response", + "description": "The response for this operation." + }, + "UpdateWorkspaceForkMappingsBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + }, + "direction": { + "type": "string", + "enum": ["push", "pull"], + "description": "Push means current to other; pull means other to current, independent of parent/child orientation." + }, + "mappings": { + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + }, + "description": "Mappings keyed by resource type and source identifier." + } + }, + "required": ["otherWorkspaceId", "direction", "mappings"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappings body", + "description": "The body for this operation." + }, + "RollbackWorkspaceForkResult": { + "type": "object", + "properties": { + "restored": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Workflows restored to their prior deployed version." + }, + "archived": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Workflows created by the sync and now archived." + }, + "unarchived": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Previously archived workflows restored by rollback." + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Snapshot workflows no longer available to restore." + }, + "pendingActivations": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + }, + "description": "Workflows whose restored deployment is still activating." + } + }, + "required": ["restored", "archived", "unarchived", "skipped", "pendingActivations"], + "additionalProperties": false, + "title": "RollbackWorkspaceForkResult", + "description": "The RollbackWorkspaceForkResult result." + }, + "RollbackWorkspaceForkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RollbackWorkspaceForkResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "RollbackWorkspaceFork response", + "description": "The response for this operation." + }, + "RollbackWorkspaceForkBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "RollbackWorkspaceFork body", + "description": "The body for this operation." + }, + "UnlinkWorkspaceForkResult": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "description": "Whether the fork edge was removed." + } + }, + "required": ["unlinked"], + "additionalProperties": false, + "title": "UnlinkWorkspaceForkResult", + "description": "The UnlinkWorkspaceForkResult result." + }, + "UnlinkWorkspaceForkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UnlinkWorkspaceForkResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "UnlinkWorkspaceFork response", + "description": "The response for this operation." + }, + "UnlinkWorkspaceForkBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "UnlinkWorkspaceFork body", + "description": "The body for this operation." + }, + "UpdateWorkspaceForkExclusionsResult": { + "type": "object", + "properties": { + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of records changed." + } + }, + "required": ["updated"], "additionalProperties": false, - "title": "Workflow run status", - "description": "Detailed current state of a workflow run." + "title": "UpdateWorkspaceForkExclusionsResult", + "description": "The UpdateWorkspaceForkExclusionsResult result." }, - "WorkflowRunStatusResponse": { + "UpdateWorkspaceForkExclusionsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunStatus" + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Workflow run status response", - "description": "Detailed current state of a workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "trigger": "api", - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000, - "paused": null, - "cost": { - "total": 12 - }, - "error": null, - "output": { - "result": "Ticket routed to Support" - }, - "blockOutputs": null, - "files": [ - { - "id": "file_1a2b3c", - "name": "summary.pdf", - "size": 20480, - "type": "application/pdf", - "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a/files/file_1a2b3c", - "base64": null - } - ] - } - } - ] + "title": "UpdateWorkspaceForkExclusions response", + "description": "The response for this operation." }, - "ResumeWorkflowSyncResponse": { + "UpdateWorkspaceForkExclusionsBody": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunResult" + "workflowIds": { + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Workflow identifiers in the current workspace." + }, + "forkSyncExcluded": { + "type": "boolean", + "description": "Whether the named workflows should be skipped as sync sources and targets." } }, - "required": ["data"], + "required": ["workflowIds", "forkSyncExcluded"], "additionalProperties": false, - "title": "Synchronous workflow resume response", - "description": "Completed, failed, paused, or cancelled resumed workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "output": { - "result": "Ticket routed to Support" - }, - "error": null, - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 - } - } - ] + "title": "UpdateWorkspaceForkExclusions body", + "description": "The body for this operation." }, - "QueuedWorkflowResume": { + "PreviewWorkflowImportResult": { "type": "object", "properties": { - "runId": { + "previewFingerprint": { "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "minLength": 64, + "maxLength": 64, + "description": "Fingerprint of the reviewed preview and its choices." }, - "statusUrl": { - "type": "string", - "format": "uri", - "description": "Absolute URL of the workflow run resource." + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "bindings": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrence": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false, + "description": "Registered source field occurrence addressed by this binding." + } + }, + "required": ["kind", "sourceId", "targetId", "required", "occurrence"], + "additionalProperties": false + }, + "description": "Resolved and unresolved source occurrences with their destination selections." + }, + "unresolvedBindings": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrence": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false, + "description": "Registered source field occurrence addressed by this binding." + } + }, + "required": ["kind", "sourceId", "targetId", "required", "occurrence"], + "additionalProperties": false + }, + "description": "Source references that still require destination mappings or explicit copy choices." + }, + "configuration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "configured": { + "type": "boolean", + "description": "Whether this destination field currently has a nonempty value." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s destination options.", + "type": "string", + "maxLength": 256 + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + }, + "description": "Allowlisted selector dependencies scoped to the destination workspace." + }, + "requiresAuthentication": { + "type": "boolean", + "description": "Whether a human must connect the provider before choices can be discovered." + } + }, + "required": [ + "blockId", + "subBlockKey", + "title", + "required", + "configured", + "context", + "requiresAuthentication" + ], + "additionalProperties": false + }, + "description": "Dependent fields that may need destination-specific values." + }, + "unresolvedConfiguration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "configured": { + "type": "boolean", + "description": "Whether this destination field currently has a nonempty value." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s destination options.", + "type": "string", + "maxLength": 256 + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + }, + "description": "Allowlisted selector dependencies scoped to the destination workspace." + }, + "requiresAuthentication": { + "type": "boolean", + "description": "Whether a human must connect the provider before choices can be discovered." + } + }, + "required": [ + "blockId", + "subBlockKey", + "title", + "required", + "configured", + "context", + "requiresAuthentication" + ], + "additionalProperties": false + }, + "description": "Required destination configuration that remains empty." }, - "queuePosition": { - "description": "Current queue position, when available.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "discovery": { + "maxItems": 32, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "maxLength": 256, + "description": "Resource or operation kind." + }, + "command": { + "type": "string", + "maxLength": 1024, + "description": "CLI command to enumerate destination candidates." + }, + "humanAuthorizationMayBeRequired": { + "type": "boolean", + "description": "Whether discovery may require a human OAuth authorization step." + } + }, + "required": ["kind", "command", "humanAuthorizationMayBeRequired"], + "additionalProperties": false + }, + "description": "CLI operations for discovering suitable destination resources." } }, - "required": ["runId", "statusUrl"], + "required": [ + "previewFingerprint", + "ready", + "bindings", + "unresolvedBindings", + "configuration", + "unresolvedConfiguration", + "discovery" + ], "additionalProperties": false, - "title": "Queued workflow resume", - "description": "Receipt returned when a resumed workflow attempt is queued." + "title": "PreviewWorkflowImportResult", + "description": "The PreviewWorkflowImportResult result." }, - "ResumeWorkflowQueuedResponse": { + "PreviewWorkflowImportResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/QueuedWorkflowResume" + "$ref": "#/components/schemas/PreviewWorkflowImportResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Queued workflow resume response", - "description": "Receipt returned when a resumed workflow attempt is queued.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" - } - } - ] + "title": "PreviewWorkflowImport response", + "description": "The response for this operation." }, - "ResumeWorkflowRequest": { + "ImportWorkflowRequest": { "type": "object", "properties": { - "contextId": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Human-in-the-loop pause-context identifier." + "maxLength": 128, + "description": "Workspace in which to import the workflow." }, - "input": { - "description": "Input supplied to the paused workflow block." - } - }, - "required": ["contextId"], - "additionalProperties": false, - "title": "Resume workflow request", - "description": "Pause context and optional input used to resume a workflow run.", - "examples": [ - { - "contextId": "ctx_123", - "input": { - "approved": true - } - } - ] - }, - "CancelWorkflowRunResult": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Whether cancellation was accepted." + "workflow": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "description": "JSON string containing a workflow export object or bare workflow state." + }, + { + "type": "object", + "additionalProperties": true, + "description": "Workflow export object or bare workflow state." + } + ], + "description": "Workflow export object, bare workflow state, or JSON string containing either form." }, - "runId": { + "folderPath": { + "description": "Destination folder path; omit for the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "name": { + "description": "Override for the imported workflow name.", "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "redisAvailable": { - "type": "boolean", - "description": "Whether the distributed cancellation channel was available." + "maxLength": 200 }, - "durablyRecorded": { - "type": "boolean", - "description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written." + "description": { + "description": "Override for the imported workflow description.", + "type": "string", + "maxLength": 2000 }, - "locallyAborted": { - "type": "boolean", - "description": "Whether an in-process execution was aborted." + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["kind", "sourceId", "targetId"], + "additionalProperties": false + } }, - "pausedCancelled": { - "type": "boolean", - "description": "Whether a paused execution was cancelled." + "bindings": { + "description": "Resolved and unresolved source occurrences with their destination selections.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "default": [], + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "default": "scalar", + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + }, + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["blockId", "subBlockKey", "kind", "targetId"], + "additionalProperties": false + } }, - "reason": { - "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", - "type": "string", - "enum": [ - "recorded", - "already_cancelled", - "already_completed", - "already_failed", - "redis_unavailable", - "redis_write_failed", - "paused_event_publish_failed", - "paused_database_cancel_failed", - "queue_cancelled", - "active_resume_signal_failed", - "cancellation_not_finalized" - ] - } - }, - "required": [ - "success", - "runId", - "redisAvailable", - "durablyRecorded", - "locallyAborted", - "pausedCancelled" - ], - "additionalProperties": false, - "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." - }, - "CancelWorkflowRunResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/CancelWorkflowRunResult" + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 16384, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["blockId", "subBlockKey", "value"], + "additionalProperties": false + } } }, - "required": ["data"], + "required": ["workspaceId", "workflow"], "additionalProperties": false, - "title": "Cancel workflow run response", - "description": "Outcome of the cancellation request.", - "examples": [ - { - "data": { - "success": true, - "runId": "run_8f14e45f-ceea-467f-a", - "redisAvailable": true, - "durablyRecorded": true, - "locallyAborted": true, - "pausedCancelled": false, - "reason": "recorded" - } - } - ] + "title": "Import workflow request", + "description": "Portable workflow data and destination metadata for an import." }, - "WorkflowFolder": { + "SelectorOption": { "type": "object", "properties": { - "name": { - "type": "string", - "description": "Folder name." - }, - "path": { - "type": "string", - "title": "Non-root folder path", - "description": "Canonical folder path used as the public folder identifier.", - "maxLength": 4096 - }, - "parentPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical parent path; `/` is the root.", - "maxLength": 4096 - }, - "createdAt": { + "id": { "type": "string", - "description": "ISO 8601 timestamp when the folder was created.", - "format": "date-time" + "minLength": 1, + "maxLength": 16384, + "description": "Provider resource identifier." }, - "updatedAt": { + "label": { "type": "string", - "description": "ISO 8601 timestamp when the folder was last updated.", - "format": "date-time" + "minLength": 1, + "maxLength": 16384, + "description": "Human-readable provider resource name." }, - "locked": { - "type": "boolean", - "description": "Whether the folder is currently locked for mutation." + "meta": { + "description": "Safe scalar metadata for presenting or configuring this choice.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 16384 + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } } }, - "required": ["name", "path", "parentPath", "createdAt", "updatedAt", "locked"], + "required": ["id", "label"], "additionalProperties": false, - "title": "Workflow folder", - "description": "A canonical workflow folder and its mutation lock state." + "title": "SelectorOption", + "description": "The SelectorOption result." }, - "WorkflowFolderListResponse": { + "ListSelectorResponse": { "type": "object", "properties": { "data": { + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowFolder" + "$ref": "#/components/schemas/SelectorOption" }, - "description": "Items in the current page." + "description": "Requested options or operation result." }, "nextCursor": { "anyOf": [ { - "type": "string" + "type": "string", + "maxLength": 32768 }, { "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Workflow folder list response", - "description": "A list of canonical workflow folders.", - "examples": [ - { - "data": [ - { - "name": "Operations", - "path": "/Operations", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - ], - "nextCursor": null - } - ] - }, - "CreateWorkflowFolderResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowFolder" + "description": "Opaque cursor for the next page. Send it back as `cursor`; null means there is nothing further to fetch. Never construct one yourself." + }, + "truncated": { + "type": "boolean", + "description": "Whether the provider returned only a bounded subset of its options." } }, - "required": ["data"], + "required": ["data", "nextCursor", "truncated"], "additionalProperties": false, - "title": "Create workflow folder response", - "description": "The created workflow folder.", - "examples": [ - { - "data": { - "name": "Operations", - "path": "/Operations", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - } - ] - }, - "NonRootFolderPathInput": { - "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "title": "ListSelector response", + "description": "The response for this operation." }, - "CreateWorkflowFolderRequest": { + "ListSelectorBody": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the folder." + "description": "Explicit current workspace scope." }, - "path": { - "description": "Path of the folder to create.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "selectorKey": { + "type": "string", + "enum": [ + "airtable.bases", + "airtable.tables", + "asana.workspaces", + "attio.lists", + "attio.objects", + "bigquery.datasets", + "bigquery.tables", + "bitbucket.workspaces", + "bitbucket.repositories", + "calcom.eventTypes", + "calcom.schedules", + "clickup.workspaces", + "clickup.spaces", + "clickup.folders", + "clickup.lists", + "confluence.spaces", + "confluence.spacesById", + "confluence.pages", + "google.tasks.lists", + "gmail.labels", + "google.calendar", + "google.drive", + "google.sheets", + "harmonic.savedSearches", + "hubspot.lists", + "hubspot.owners", + "hubspot.pipelines", + "hubspot.pipelineStages", + "hubspot.properties", + "jsm.requestTypes", + "jsm.serviceDesks", + "microsoft.planner.plans", + "notion.databases", + "notion.pages", + "netsuite.recordTypes", + "netsuite.asyncTasks", + "pipedrive.pipelines", + "sharepoint.lists", + "trello.boards", + "zoho_desk.organizations", + "zoho_desk.departments", + "zoho_desk.agents", + "zoom.meetings", + "slack.channels", + "snowflake.databases", + "snowflake.schemas", + "snowflake.tables", + "snowflake.warehouses", + "snowflake.roles", + "snowflake.fileFormats", + "snowflake.procedures", + "slack.users", + "outlook.folders", + "outlook.calendars", + "microsoft.teams", + "microsoft.chats", + "microsoft.channels", + "microsoft.planner", + "onedrive.files", + "onedrive.folders", + "sharepoint.sites", + "microsoft.excel", + "microsoft.excel.drives", + "microsoft.excel.sheets", + "microsoft.word", + "wealthbox.contacts", + "jira.issues", + "jira.projects", + "linear.projects", + "linear.teams", + "monday.boards", + "monday.groups", + "webflow.sites", + "webflow.collections", + "webflow.items", + "cloudwatch.logGroups", + "cloudwatch.logStreams", + "imap.mailboxes", + "mcp.tools", + "managedAgent.agents", + "managedAgent.environments", + "managedAgent.vaults", + "managedAgent.memoryStores", + "knowledge.documents", + "sim.workflows", + "table.columns", + "table.outputColumns", + "workspace.secretNames", + "workspace.sandboxes", + "providers.ollamaEmbeddingModels", + "providers.openrouterEmbeddingModels" + ], + "description": "Registered selector key for discovering this field’s destination options." + }, + "context": { + "default": {}, + "description": "Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + } + }, + "search": { + "description": "Provider option search text.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "cursor": { + "description": "Opaque continuation cursor returned by the preceding page.", + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "limit": { + "default": 50, + "description": "Maximum number of items to return on one page.", + "type": "integer", + "minimum": 1, + "maximum": 100 } }, - "required": ["workspaceId", "path"], + "required": ["workspaceId", "selectorKey"], "additionalProperties": false, - "title": "Create workflow folder request", - "description": "Workspace and canonical path for a new workflow folder." + "title": "ListSelector body", + "description": "The body for this operation." }, - "RelocateWorkflowFolderResponse": { + "GetSelectorResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowFolder" + "anyOf": [ + { + "$ref": "#/components/schemas/SelectorOption" + }, + { + "type": "null" + } + ], + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Relocate workflow folder response", - "description": "The relocated workflow folder.", - "examples": [ - { - "data": { - "name": "Support", - "path": "/Support", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - } - ] + "title": "GetSelector response", + "description": "The response for this operation." }, - "RelocateWorkflowFolderRequest": { + "GetSelectorBody": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace containing the folder." - }, - "path": { - "description": "Current folder path.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Explicit current workspace scope." }, - "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" - } - }, - "required": ["workspaceId", "path", "destinationPath"], - "additionalProperties": false, - "title": "Relocate workflow folder request", - "description": "Current and destination paths for a workflow folder." - }, - "DeleteWorkflowFolderResult": { - "type": "object", - "properties": { - "path": { + "selectorKey": { "type": "string", - "title": "Folder path", - "description": "Path of the deleted workflow folder.", - "maxLength": 4096 - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the folder was deleted." + "enum": [ + "airtable.bases", + "airtable.tables", + "asana.workspaces", + "attio.lists", + "attio.objects", + "bigquery.datasets", + "bigquery.tables", + "bitbucket.workspaces", + "bitbucket.repositories", + "calcom.eventTypes", + "calcom.schedules", + "clickup.workspaces", + "clickup.spaces", + "clickup.folders", + "clickup.lists", + "confluence.spaces", + "confluence.spacesById", + "confluence.pages", + "google.tasks.lists", + "gmail.labels", + "google.calendar", + "google.drive", + "google.sheets", + "harmonic.savedSearches", + "hubspot.lists", + "hubspot.owners", + "hubspot.pipelines", + "hubspot.pipelineStages", + "hubspot.properties", + "jsm.requestTypes", + "jsm.serviceDesks", + "microsoft.planner.plans", + "notion.databases", + "notion.pages", + "netsuite.recordTypes", + "netsuite.asyncTasks", + "pipedrive.pipelines", + "sharepoint.lists", + "trello.boards", + "zoho_desk.organizations", + "zoho_desk.departments", + "zoho_desk.agents", + "zoom.meetings", + "slack.channels", + "snowflake.databases", + "snowflake.schemas", + "snowflake.tables", + "snowflake.warehouses", + "snowflake.roles", + "snowflake.fileFormats", + "snowflake.procedures", + "slack.users", + "outlook.folders", + "outlook.calendars", + "microsoft.teams", + "microsoft.chats", + "microsoft.channels", + "microsoft.planner", + "onedrive.files", + "onedrive.folders", + "sharepoint.sites", + "microsoft.excel", + "microsoft.excel.drives", + "microsoft.excel.sheets", + "microsoft.word", + "wealthbox.contacts", + "jira.issues", + "jira.projects", + "linear.projects", + "linear.teams", + "monday.boards", + "monday.groups", + "webflow.sites", + "webflow.collections", + "webflow.items", + "cloudwatch.logGroups", + "cloudwatch.logStreams", + "imap.mailboxes", + "mcp.tools", + "managedAgent.agents", + "managedAgent.environments", + "managedAgent.vaults", + "managedAgent.memoryStores", + "knowledge.documents", + "sim.workflows", + "table.columns", + "table.outputColumns", + "workspace.secretNames", + "workspace.sandboxes", + "providers.ollamaEmbeddingModels", + "providers.openrouterEmbeddingModels" + ], + "description": "Registered selector key for discovering this field’s destination options." }, - "deletedItems": { + "context": { + "default": {}, + "description": "Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.", "type": "object", - "properties": { - "folders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of folders deleted." - }, - "workflows": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of workflows deleted." - } + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "required": ["folders", "workflows"], - "additionalProperties": false, - "description": "Resources removed by the deletion." + "additionalProperties": { + "type": "string", + "maxLength": 16384 + } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 16384, + "description": "Resource identifier." } }, - "required": ["path", "deleted", "deletedItems"], + "required": ["workspaceId", "selectorKey", "id"], "additionalProperties": false, - "title": "Delete workflow folder result", - "description": "Confirmation and deletion counts for a workflow folder." + "title": "GetSelector body", + "description": "The body for this operation." }, - "DeleteWorkflowFolderResponse": { + "GetWorkspaceOperationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowFolderResult" + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow folder response", - "description": "Confirmation and counts for the deleted folder.", - "examples": [ - { - "data": { - "path": "/Operations", - "deleted": true, - "deletedItems": { - "folders": 1, - "workflows": 0 + "title": "GetWorkspaceOperation response", + "description": "The response for this operation." + }, + "ListWorkspaceOperationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceOperationReport" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } - } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } - ] + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceOperations response", + "description": "The response for this operation." } } }, diff --git a/apps/sim/app/api/v2/selectors/get/route.ts b/apps/sim/app/api/v2/selectors/get/route.ts new file mode 100644 index 00000000000..4910299fb84 --- /dev/null +++ b/apps/sim/app/api/v2/selectors/get/route.ts @@ -0,0 +1,22 @@ +import { v2GetSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SelectorErrorPolicy } from '@/lib/selectors/api/error-policy' +import { getSelectorOption } from '@/lib/selectors/application/get-selector-option' +import { selectorOperations } from '@/lib/selectors/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2GetSelectorContract, + auth: v2ApiKeyAuth, + operation: selectorOperations.execute, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SelectorErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }) => ({ + selectorKey: body.selectorKey, + context: body.context, + scope: { kind: 'workspace' as const, workspaceId: body.workspaceId }, + id: body.id, + }), + useCase: getSelectorOption, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/selectors/list/route.ts b/apps/sim/app/api/v2/selectors/list/route.ts new file mode 100644 index 00000000000..8cd0f0746ef --- /dev/null +++ b/apps/sim/app/api/v2/selectors/list/route.ts @@ -0,0 +1,17 @@ +import { v2ListSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SelectorErrorPolicy } from '@/lib/selectors/api/error-policy' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { listSelector } from '@/lib/selectors/application/paged-selector' + +export const POST = defineV2JsonRoute({ + contract: v2ListSelectorContract, + auth: v2ApiKeyAuth, + operation: selectorOperations.execute, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SelectorErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }) => body, + useCase: listSelector, + present: ({ items, nextCursor, truncated }) => ({ data: items, nextCursor, truncated }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts index 93b622d85bf..fda2ae9f317 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts @@ -19,7 +19,10 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, headSafe: false, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.workflowId }), + mapInput: ({ params, query }) => ({ + workflowId: params.workflowId, + includeReferences: query.includeReferences === true, + }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/import/preview/route.ts b/apps/sim/app/api/v2/workflows/import/preview/route.ts new file mode 100644 index 00000000000..67eb12f7c03 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/preview/route.ts @@ -0,0 +1,18 @@ +import { v2PreviewWorkflowImportContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { previewWorkflowImport } from '@/lib/workflows/application/mapped-import' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkflowImportContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.importPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ body }) => body, + useCase: previewWorkflowImport, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index c4a9a7ea310..1291bfaeae0 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -15,16 +15,11 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.import, parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, - mapInput: ({ body }) => ({ - workspaceId: body.workspaceId, - folderPath: body.folderPath, - name: body.name, - description: body.description, - workflow: body.workflow, - }), + mapInput: ({ body }) => body, useCase: importWorkflow, - present: ({ workflow, folderPath }) => ({ + present: ({ workflow, folderPath, operation }) => ({ data: { + ...operation, id: workflow.id, name: workflow.name, description: workflow.description, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts new file mode 100644 index 00000000000..29c0c719c48 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceForkAvailabilityContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkAvailability } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkAvailabilityContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params }) => params, + useCase: getWorkspaceForkAvailability, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts new file mode 100644 index 00000000000..36214dec960 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceForkChildrenContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { listWorkspaceForkChildren } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceForkChildrenContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceForkChildren, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts new file mode 100644 index 00000000000..3cb1b423b0a --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts @@ -0,0 +1,17 @@ +import { v2UpdateWorkspaceForkExclusionsContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkExclusions } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateWorkspaceForkExclusionsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.exclusions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateWorkspaceForkExclusions, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts new file mode 100644 index 00000000000..12380b787a9 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceForkLineageContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkLineage } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkLineageContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params }) => params, + useCase: getWorkspaceForkLineage, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts new file mode 100644 index 00000000000..b989c578929 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts @@ -0,0 +1,32 @@ +import { + v2GetWorkspaceForkMappingsContract, + v2UpdateWorkspaceForkMappingsContract, +} from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkMappings } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkMappingsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.mappingsRead, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: getWorkspaceForkMappings, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateWorkspaceForkMappingsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.mappingsUpdate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateWorkspaceForkMappings, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts new file mode 100644 index 00000000000..e6f52739352 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts @@ -0,0 +1,17 @@ +import { v2PreviewWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceFork } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.preview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: previewWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts new file mode 100644 index 00000000000..a7b10d802ee --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts @@ -0,0 +1,25 @@ +import { v2PreviewWorkspacePullContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceSync } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspacePullContract, + auth: v2ApiKeyAuth, + operation: forkOperations.syncPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'pull' as const, + } + }, + useCase: previewWorkspaceSync, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts new file mode 100644 index 00000000000..a18cf6f5752 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts @@ -0,0 +1,25 @@ +import { v2PullWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PullWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.sync, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, confirm: _confirm, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'pull' as const, + } + }, + useCase: syncWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts new file mode 100644 index 00000000000..5987da20835 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts @@ -0,0 +1,25 @@ +import { v2PreviewWorkspacePushContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceSync } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspacePushContract, + auth: v2ApiKeyAuth, + operation: forkOperations.syncPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'push' as const, + } + }, + useCase: previewWorkspaceSync, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts new file mode 100644 index 00000000000..a86f9a1dd8c --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts @@ -0,0 +1,25 @@ +import { v2PushWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PushWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.sync, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, confirm: _confirm, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'push' as const, + } + }, + useCase: syncWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts new file mode 100644 index 00000000000..0d065b9797b --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceForkResourcesContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { listWorkspaceForkResources } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceForkResourcesContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceForkResources, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts new file mode 100644 index 00000000000..c3951477ff1 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts @@ -0,0 +1,17 @@ +import { v2RollbackWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { rollbackWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineV2JsonRoute({ + contract: v2RollbackWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.rollback, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: rollbackWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts new file mode 100644 index 00000000000..07672b5939d --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts @@ -0,0 +1,17 @@ +import { v2ForkWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ForkWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: forkWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts new file mode 100644 index 00000000000..2ffac025941 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts @@ -0,0 +1,17 @@ +import { v2UnlinkWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { unlinkWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineV2JsonRoute({ + contract: v2UnlinkWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.unlink, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: unlinkWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts new file mode 100644 index 00000000000..d9aa5cb8e06 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceOperationContract } from '@/lib/api/contracts/v2/workspace-operations' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { getWorkspaceOperation } from '@/lib/workspaces/operations/application' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceOperationContract, + auth: v2ApiKeyAuth, + operation: workspaceOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Operation not found' }), + mapInput: ({ params }) => params, + useCase: getWorkspaceOperation, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts new file mode 100644 index 00000000000..e78a8c78093 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceOperationsContract } from '@/lib/api/contracts/v2/workspace-operations' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { listWorkspaceOperations } from '@/lib/workspaces/operations/application' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceOperationsContract, + auth: v2ApiKeyAuth, + operation: workspaceOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Operation not found' }), + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceOperations, + present: ({ operations, nextCursor }) => ({ data: operations, nextCursor }), +}) diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index d24f70fab6a..f79d06a4e28 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -23,6 +23,8 @@ import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/works 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 { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' const logger = createLogger('OutboxProcessorAPI') @@ -45,6 +47,8 @@ const handlers = { ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, ...workflowDeploymentOutboxHandlers, + ...workspaceOperationOutboxHandlers, + ...forkContentOutboxHandlers, } as const export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts index f7ea1ec10bc..d37e9005765 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts @@ -1,36 +1,20 @@ -import { type NextRequest, NextResponse } from 'next/server' import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkAvailability } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' -/** - * Whether forking is available for this workspace based on deployment configuration - * and plan. Member-readable because it only reveals availability; the client uses it - * to show or hide Forks settings and context-menu entries. - */ -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkAvailabilityContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - const access = await checkWorkspaceAccess(id, session.user.id) - if (!access.exists || !access.workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const available = await isForkingAvailableForWorkspace( - access.workspace.organizationId, - session.user.id - ) - return NextResponse.json({ available }) - } -) +export const GET = defineInternalJsonRoute({ + contract: getForkAvailabilityContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkAvailability, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index 4425c1a4675..59da9070885 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -1,288 +1,20 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { - listForkExcludedDeployedWorkflows, - loadSourceDeployedStates, - loadTargetWebhookPathsByBlock, -} from '@/ee/workspace-forking/lib/copy/deploy-bridge' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' -import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs' -import { - collectForkDependentReconfigs, - collectForkResourceUsages, -} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs' -import { - forkDependentValueKey, - loadForkDependentValues, -} from '@/ee/workspace-forking/lib/mapping/dependent-value-store' -import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources' -import { - annotateForkClearedRefSourceLiveness, - collectForkClearedRefCandidates, -} from '@/ee/workspace-forking/lib/promote/cleared-refs' -import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' -import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls' -import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references' - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkDiffContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction } = parsed.data.query - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates( - auth.sourceWorkspaceId - ) - const plan = await computeForkPromotePlan({ - executor: db, - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - direction, - deployedSourceWorkflows: deployedWorkflows, - sourceStates, - }) - - // Resolve dependent-reconfig target block ids through the SAME persisted block map the - // sync will use, so a re-pick the modal keys by target block id lands on the block the - // promote actually writes (on push that's the parent's original id, not a derived one). - const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId - const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId) - const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) - - // Stored dependent values are the source of truth for what each selector is set to. Overlay - // them as each field's currentValue so the modal pre-fills what the user actually saved. - // Before the FIRST sync populates the store (fork-create seeds mappings but no dependent - // values), the fallback is the TARGET's own configured value (loaded from its draft) - never - // the source's, which would overwrite the target's selection. The stored read spans EVERY - // plan target: a create-mode (never-synced) workflow's deterministic target id is what the - // first sync will use, so values pre-configured for it in the mapping editor pre-fill here - // too. The draft read stays replace-scoped (creates have no target draft to fall back to). - const replaceTargetIds = plan.items - .filter((item) => item.mode === 'replace') - .map((item) => item.targetWorkflowId) - const allTargetIds = plan.items.map((item) => item.targetWorkflowId) - const [ - storedValues, - targetDraftByWorkflow, - sourceCandidates, - sourceWorkflowRows, - excludedSourceWorkflows, - ] = await Promise.all([ - loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), - loadTargetDraftSubBlocks(db, replaceTargetIds), - // Source resource labels (per kind) + workflow names, for the cleared-ref list's display. - listForkResourceCandidates(db, auth.sourceWorkspaceId), - db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), - // Deployed-but-excluded source workflows, so the preview can show what a sync skips. - listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId), - ]) - const storedByKey = new Map( - storedValues.map((entry) => [ - forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey), - entry.value, - ]) - ) - - // Source block subBlocks keyed by their resolved target identity, so the first-sync draft - // fallback can identity-check a nested tool against the SOURCE dependent tool it came from - - // an index alone may point at a different tool in the target draft, whose value isn't the - // dependent's. Read structurally (only each subblock's `value`), so the in-memory state's - // blocks pass without a cast. - const sourceBlocksByTarget = new Map>>() - for (const item of plan.items) { - if (item.mode !== 'replace') continue - const state = sourceStates.get(item.sourceWorkflowId) - if (!state) continue - const byBlock = new Map>() - for (const [sourceBlockId, block] of Object.entries(state.blocks)) { - byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {}) - } - sourceBlocksByTarget.set(item.targetWorkflowId, byBlock) - } - - // Replace-target fields pre-fill from the store, falling back to the TARGET's own draft - // value before the first sync populates the store (never the source's, which would - // overwrite the target's selection). Create-target fields (never-synced workflows) - // pre-fill from the store, falling back to the SOURCE value the collector emitted - - // that's exactly what the first sync copies verbatim, so the pre-fill is honest and - // configuring it ahead of the first sync is possible (the deterministic target ids - // already exist). - // Custom-block inputs join the same list: repointing a block makes every one of its - // inputs reconfigurable (see `collectForkCustomBlockReconfigs`), and they store, pre-fill, - // gate Sync, and apply through this identical channel. - const customBlockReconfigs = await collectForkCustomBlockReconfigs({ - items: plan.items, - sourceStates, - resolveTargetBlockId: resolveBlockId, - resolve: plan.resolver, - targetWorkspaceId: plan.targetWorkspaceId, - }) - - const dependentReconfigs = [ - ...customBlockReconfigs.map((field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? field.currentValue, - })), - ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? - readTargetDraftDependentValue( - targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks, - sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId), - field.subBlockKey - ), - })), - ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map( - (field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? field.currentValue, - }) - ), - ] - - // References this sync will blank in the target (per block/field), for the pre-sync cleared-ref - // list. Labels resolve from the source candidate lists + workflow names loaded above. - const sourceLabels = new Map() - for (const [kind, candidates] of Object.entries(sourceCandidates)) { - for (const candidate of candidates) - sourceLabels.set(`${kind}:${candidate.id}`, candidate.label) - } - const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name])) - // Annotate each reference-cause entry's source liveness so the client can phrase the blocker - // reason (a deleted source can't be copied - it must be mapped to a live target resource). - const clearedRefs = await annotateForkClearedRefSourceLiveness( - db, - auth.sourceWorkspaceId, - collectForkClearedRefCandidates({ - items: plan.items, - sourceStates, - resolver: plan.resolver, - workflowIdMap: plan.workflowIdMap, - resolveBlockId, - sourceLabels, - sourceWorkflowNames, - }) - ) - - // Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request - // URL again" case, surfaced as an editable pairing before the overwrite instead of discovered - // after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the - // promote call, where the same plan is rebuilt and validated against them. - const triggerPlan = buildForkTriggerPlan({ - items: plan.items, - sourceStates, - resolveBlockId, - targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds), - }) - // The RAW retiring set, not the default resolution: the client derives which of these actually - // stop being served from the picks the user is making right now, so the heads-up and the - // overwrite confirm can never disagree with the Trigger URLs rows. - const retiringTriggerUrls = triggerPlan.retiring.map((row) => ({ - workflowName: row.workflowName, - path: row.path, - })) - // Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just - // the decisions, so the section reads as a standing statement of each URL rather than an alert. - // - // A trigger with neither is deliberately absent: whether a block will serve a URL at all is - // only knowable from its webhook row, and a schedule / chat / manual / poller trigger never - // gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative - // flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on - // 345 including `slack_oauth`, which routes by `routingKey` with a NULL path. - const triggerMappings = triggerPlan.slots - .filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0) - .map((slot) => ({ - sourceBlockId: slot.sourceBlockId, - blockName: slot.blockName, - workflowName: slot.workflowName, - ownPath: slot.ownPath, - adoptablePaths: slot.adoptablePaths, - defaultAdoptPath: slot.defaultAdoptPath, - })) - - const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ - kind: reference.kind, - sourceId: reference.sourceId, - required: reference.required, - blockName: reference.blockName, - }) - - // Orient the mapping around the workspace the modal is open in (`id`): show the - // caller's workflow name first, the sync partner's second, so renames are legible. - const currentIsSource = auth.sourceWorkspaceId === id - const workflows = [ - ...plan.items.map((item) => { - if (item.mode === 'create') { - // The target inherits the source's name, so both sides read the same. - return { - action: 'create' as const, - currentName: item.sourceMeta.name, - otherName: item.sourceMeta.name, - } - } - const targetName = item.targetName ?? item.sourceMeta.name - return { - action: 'update' as const, - currentName: currentIsSource ? item.sourceMeta.name : targetName, - otherName: currentIsSource ? targetName : item.sourceMeta.name, - } - }), - ...plan.archivedTargets.map((target) => ({ - action: 'archive' as const, - currentName: target.name, - otherName: target.name, - })), - ] - - return NextResponse.json({ - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - willUpdate: plan.willUpdate, - willCreate: plan.willCreate, - willArchive: plan.willArchive, - workflows, - excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name), - excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name), - unmappedRequired: plan.unmappedRequired.map(toRef), - unmappedOptional: plan.unmappedOptional.map(toRef), - mcpReauthServerIds: plan.mcpReauthServerIds, - inlineSecretSources: plan.inlineSecretSources, - dependentReconfigs, - resourceUsages: collectForkResourceUsages(plan.items, sourceStates), - copyableUnmapped: plan.copyableUnmapped, - clearedRefs, - retiringTriggerUrls, - triggerMappings, - }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceSyncDetails } from '@/ee/workspace-forking/application/sync-details' + +export const GET = defineInternalJsonRoute({ + contract: getForkDiffContract, + auth: internalSessionAuth, + operation: forkOperations.syncPreview, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceSyncDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts index f31acbbc923..f6064e7e6f5 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -11,13 +11,29 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted(() => ({ - mockAssertWorkspaceAdminAccess: vi.fn(), - mockCaptureServerEvent: vi.fn(), -})) +const { mockAuthorizeWorkspaceOperation, mockCaptureServerEvent, mockAssertForkingEnabled } = + vi.hoisted(() => ({ + mockAuthorizeWorkspaceOperation: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockAssertForkingEnabled: vi.fn(), + })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, + assertForkingEnabled: mockAssertForkingEnabled, + ForkError: class extends Error {}, +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: 'My Workspace', + organizationId: null, + allowPersonalApiKeys: true, + })), })) vi.mock('@sim/audit', () => auditMock) @@ -42,8 +58,8 @@ describe('fork excluded-workflows route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } }) - mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' }) + mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) mockUpdateReturning([]) }) @@ -60,7 +76,7 @@ describe('fork excluded-workflows route', () => { ) expect(res.status).toBe(401) - expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + expect(mockAuthorizeWorkspaceOperation).not.toHaveBeenCalled() }) it('rejects an empty workflowIds batch', async () => { @@ -81,7 +97,16 @@ describe('fork excluded-workflows route', () => { routeContext ) - expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, ADMIN_ID) + expect(mockAuthorizeWorkspaceOperation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: ADMIN_ID }), + expect.objectContaining({ id: 'workspaces.fork.exclusions', minimumRole: 'admin' }), + expect.objectContaining({ workspaceId: WORKSPACE_ID }), + {} + ) + expect(mockAssertForkingEnabled).toHaveBeenCalledWith(null) + expect(mockAssertForkingEnabled.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.update.mock.invocationCallOrder[0] + ) }) it('updates the batch, reports the transition count, and records one audit entry', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts index 842713be6d0..75fd2f0f8a8 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts @@ -1,94 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { updateForkExcludedWorkflowsContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' - -const logger = createLogger('ForkExcludedWorkflowsAPI') - -/** Workflow names carried on the audit entry - bounds the row for very large batches. */ -const AUDIT_NAME_LIMIT = 20 - -/** - * Toggle "Exclude from sync" for a batch of the workspace's workflows. An excluded - * workflow never leaves its workspace (promote in either direction, new-fork copies), - * is never overwritten or archived as a sync target, and keeps its identity mapping - * so re-including it resumes replace-mode. Admin-only, matching the sync operations - * the flag governs. Ids outside the workspace, archived workflows, and workflows - * already at the requested value are skipped, so `updated` counts real transitions. - */ -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateForkExcludedWorkflowsContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { workflowIds, forkSyncExcluded } = parsed.data.body - - const adminWorkspace = await assertWorkspaceAdminAccess(workspaceId, session.user.id) - - const updatedRows = await db - .update(workflow) - .set({ forkSyncExcluded, updatedAt: new Date() }) - .where( - and( - inArray(workflow.id, workflowIds), - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt), - ne(workflow.forkSyncExcluded, forkSyncExcluded) - ) - ) - .returning({ id: workflow.id, name: workflow.name }) - - if (updatedRows.length > 0) { - recordAudit({ - workspaceId, - actorId: session.user.id, - action: forkSyncExcluded - ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED - : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: adminWorkspace.name, - description: `${forkSyncExcluded ? 'Excluded' : 'Included'} ${updatedRows.length} workflow(s) ${forkSyncExcluded ? 'from' : 'in'} fork sync`, - metadata: { - forkSyncExcluded, - workflowCount: updatedRows.length, - workflowNames: updatedRows.slice(0, AUDIT_NAME_LIMIT).map((row) => row.name), - }, - }) - - captureServerEvent( - session.user.id, - 'fork_excluded_workflows_updated', - { - workspace_id: workspaceId, - workflow_count: updatedRows.length, - fork_sync_excluded: forkSyncExcluded, - }, - { groups: { workspace: workspaceId } } - ) - } - - logger.info('Updated fork-sync exclusion', { - workspaceId, - requested: workflowIds.length, - updated: updatedRows.length, - forkSyncExcluded, - }) - - return NextResponse.json({ updated: updatedRows.length }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkExclusions } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const PUT = defineInternalJsonRoute({ + contract: updateForkExcludedWorkflowsContract, + auth: internalSessionAuth, + operation: forkOperations.exclusions, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: updateWorkspaceForkExclusions, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts index c3c265ba4c6..5bf0cbd759b 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts @@ -3,15 +3,16 @@ */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { - mockAssertWorkspaceAdminAccess, + mockAuthorizeWorkspaceOperation, mockGetForkParent, mockGetForkChildren, mockGetUndoableRunForTarget, mockGetEffectiveWorkspacePermission, } = vi.hoisted(() => ({ - mockAssertWorkspaceAdminAccess: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), mockGetForkParent: vi.fn(), mockGetForkChildren: vi.fn(), mockGetUndoableRunForTarget: vi.fn(), @@ -19,7 +20,8 @@ const { })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, })) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ @@ -31,7 +33,17 @@ vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({ getUndoableRunForTarget: mockGetUndoableRunForTarget, })) +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + organizationId: null, + allowPersonalApiKeys: true, + })), getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission, })) @@ -55,8 +67,8 @@ const childNode = (id: string, name: string) => ({ describe('fork lineage route', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } }) - mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) mockGetForkParent.mockResolvedValue(null) mockGetForkChildren.mockResolvedValue([]) mockGetUndoableRunForTarget.mockResolvedValue(null) @@ -69,13 +81,34 @@ describe('fork lineage route', () => { const res = await GET(createMockRequest('GET'), routeContext) expect(res.status).toBe(401) - expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + expect(mockAuthorizeWorkspaceOperation).not.toHaveBeenCalled() }) it('requires admin on the current workspace before loading lineage', async () => { await GET(createMockRequest('GET'), routeContext) - expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID) + expect(mockAuthorizeWorkspaceOperation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: VIEWER_ID }), + expect.objectContaining({ id: 'workspaces.fork.discover', minimumRole: 'admin' }), + expect.objectContaining({ workspaceId: WORKSPACE_ID }), + {} + ) + expect(mockAuthorizeWorkspaceOperation.mock.invocationCallOrder[0]).toBeLessThan( + mockGetForkParent.mock.invocationCallOrder[0] + ) + }) + + it('does not read lineage when current workspace authorization is refused', async () => { + mockAuthorizeWorkspaceOperation.mockRejectedValue( + new OrchestrationError('forbidden', 'Admin access required') + ) + + const response = await GET(createMockRequest('GET'), routeContext) + + expect(response.status).toBe(403) + expect(mockGetForkParent).not.toHaveBeenCalled() + expect(mockGetForkChildren).not.toHaveBeenCalled() + expect(mockGetUndoableRunForTarget).not.toHaveBeenCalled() }) it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts index 6e8b5b364e5..daf02b06675 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts @@ -1,82 +1,20 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' -import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' -import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' - -/** - * Annotates a lineage node with whether the viewer holds any access to it (explicit - * grant or org-admin derivation, via the canonical workspace-permission resolver). - * Lineage rows are visible to any admin of the CURRENT workspace, who may have no - * access to the other side of an edge; the flag drives per-action gating in the - * Forks UI. Resolved per node - lineage children lists are small and bounded. - */ -async function withViewerAccess( - node: T, - viewerId: string -): Promise { - const permission = await getEffectiveWorkspacePermission(viewerId, node) - return { ...node, viewerAccessible: permission !== null } -} - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkLineageContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - - await assertWorkspaceAdminAccess(workspaceId, session.user.id) - - const [rawParent, rawChildren, run] = await Promise.all([ - getForkParent(workspaceId), - getForkChildren(workspaceId), - getUndoableRunForTarget(db, workspaceId), - ]) - - const [parent, children] = await Promise.all([ - rawParent ? withViewerAccess(rawParent, session.user.id) : null, - Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))), - ]) - - let undoableRun: { - otherWorkspaceId: string - otherName: string - direction: 'push' | 'pull' - } | null = null - if (run) { - const [other] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, run.sourceWorkspaceId)) - .limit(1) - undoableRun = { - otherWorkspaceId: run.sourceWorkspaceId, - otherName: other?.name ?? 'workspace', - direction: run.direction, - } - } - - return NextResponse.json({ - workspaceId, - parent, - children: children.map((child) => ({ - ...child, - createdAt: child.createdAt.toISOString(), - })), - undoableRun, - }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkLineageDetails } from '@/ee/workspace-forking/application/lineage-details' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: getForkLineageContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkLineageDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts index 63dc41e5e20..91a9f077cb3 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts @@ -1,104 +1,40 @@ -import { db } from '@sim/db' -import { type NextRequest, NextResponse } from 'next/server' import { getForkMappingContract, updateForkMappingContract, } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage' -import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' import { - applyForkMappingEntries, - getForkMappingView, - validateForkMappingTargets, -} from '@/ee/workspace-forking/lib/mapping/mapping-service' - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkMappingContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction } = parsed.data.query - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - const { entries } = await getForkMappingView({ - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - }) - - return NextResponse.json({ - childWorkspaceId: auth.edge.childWorkspaceId, - parentWorkspaceId: auth.edge.parentWorkspaceId, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - entries, - }) - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateForkMappingContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction, entries, dependentValues } = parsed.data.body - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries) - - // Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted - // then re-upserted parent-side) can't leave duplicate rows for the same source. Same - // edge lock promote/rollback use, with a bounded wait. - const updated = await db.transaction(async (tx) => { - await setForkLockTimeout(tx) - await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId) - const applied = await applyForkMappingEntries( - tx, - auth.edge, - session.user.id, - direction, - entries - ) - // Store dependent-field values with the mapping (each named workflow's stored set is - // replaced by exactly what was sent - promote's reconcile semantics, scoped to the - // payload's workflows since a mapping save has no promote plan). Omitted = untouched; - // rows for a workflow that never becomes a sync replace target are inert (promote - // loads the store scoped to its plan's targets). - if (dependentValues !== undefined) { - const targetWorkflowIds = Array.from( - new Set(dependentValues.map((entry) => entry.workflowId)) - ) - await reconcileForkDependentValues( - tx, - auth.edge.childWorkspaceId, - targetWorkflowIds, - dependentValues.map((entry) => ({ - targetWorkflowId: entry.workflowId, - targetBlockId: entry.blockId, - subBlockKey: entry.subBlockKey, - value: entry.value, - })) - ) - } - return applied - }) - - return NextResponse.json({ success: true as const, updated }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkMappingDetails } from '@/ee/workspace-forking/application/mapping-details' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const GET = defineInternalJsonRoute({ + contract: getForkMappingContract, + auth: internalSessionAuth, + operation: forkOperations.mappingsRead, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceForkMappingDetails, + present: (result) => result, +}) +export const PUT = defineInternalJsonRoute({ + contract: updateForkMappingContract, + auth: internalSessionAuth, + operation: forkOperations.mappingsUpdate, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + otherWorkspaceId: body.otherWorkspaceId, + direction: body.direction, + mappings: body.entries, + dependentValues: body.dependentValues, + }), + useCase: updateWorkspaceForkMappings, + present: (result) => ({ success: true as const, ...result }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts index a634de5425e..d78b02188b9 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts @@ -8,11 +8,13 @@ * * @vitest-environment node */ +import { user } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FolderCollectionFullError } from '@/lib/folders/errors' -const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ({ +const { mockLogger, mockPromoteFork, mockAuthorizeWorkspaceOperation } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), @@ -23,7 +25,7 @@ const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ( child: vi.fn(), }, mockPromoteFork: vi.fn(), - mockAssertCanPromote: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -34,7 +36,27 @@ vi.mock('@sim/logger', () => ({ })) vi.mock('@/ee/workspace-forking/lib/promote/promote', () => ({ promoteFork: mockPromoteFork })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertCanPromote: mockAssertCanPromote, + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: id === 'ws-child' ? 'Child' : 'Parent', + organizationId: null, + allowPersonalApiKeys: true, + })), +})) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ + resolveForkEdge: vi.fn(async () => ({ + childWorkspaceId: 'ws-child', + parentWorkspaceId: 'ws-parent', + })), })) import { POST } from '@/app/api/workspaces/[id]/fork/promote/route' @@ -58,19 +80,15 @@ function promoteRequest() { describe('POST /api/workspaces/[id]/fork/promote', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER }) - mockAssertCanPromote.mockResolvedValue({ - edge: { childWorkspaceId: WORKSPACE_ID }, - sourceWorkspaceId: WORKSPACE_ID, - targetWorkspaceId: 'ws-parent', - source: { name: 'Child' }, - target: { name: 'Parent' }, - }) + authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER, session: { id: 'session-1' } }) + resetDbChainMock() + queueTableRows(user, [{ name: TEST_USER.name }]) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) }) /** - * The sync's Activity row is recorded by the use case, not here, so the route's job is to - * hand it the one thing only the route knows: the display name of the edge's other side. + * The shared application use case resolves the other side's name and the actor attribution + * before the manager records the sync activity. */ it('names the other side of the edge for promoteFork to record the sync', async () => { mockPromoteFork.mockResolvedValue({ @@ -80,6 +98,7 @@ describe('POST /api/workspaces/[id]/fork/promote', () => { archived: 0, redeployed: 1, deployFailed: 0, + deployWarnings: [], unmappedRequired: [], blockers: [], blocked: null, diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts index af76a8d0e01..804270cfde2 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -1,119 +1,34 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { promoteForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote' - -const logger = createLogger('WorkspaceForkPromoteAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(promoteForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { - otherWorkspaceId, - direction, - dependentValues, - copyResources, - dropReferences, - triggerMappings, - } = parsed.data.body - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - const otherName = - otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name - - let result: Awaited> - try { - result = await promoteFork({ - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - direction, - userId: session.user.id, - actorName: session.user.name ?? undefined, - otherWorkspaceName: otherName, - dependentValues, - copyResources, - dropReferences, - triggerMappings, - requestId, - }) - } catch (error) { - /** - * `promoteFork` returns its deliberate refusals as a `blocked` result, but a - * classified failure raised deeper in the copy — the target workspace's folder - * ceiling being full, for one — throws instead. Without this branch it reaches - * `withRouteHandler`, which only understands `HttpError` and renders everything else - * as an opaque `Internal server error` 500. Unwrapped from the cause chain because - * drizzle re-wraps anything thrown inside a transaction callback. - */ - const classified = asOrchestrationError(error) - if (!classified) throw error - logger.warn(`[${requestId}] Fork sync refused: ${classified.message}`) - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - - const body = { - promoteRunId: result.promoteRunId, - updated: result.updated, - created: result.created, - archived: result.archived, - redeployed: result.redeployed, - deployFailed: result.deployFailed, - unmappedRequired: result.unmappedRequired, - blockers: result.blockers, - needsConfiguration: result.needsConfiguration, - clearedOptional: result.clearedOptional, - droppedReferences: result.droppedReferences, - triggerUrlChanges: result.triggerUrlChanges, - } - - if (result.blocked) { - logger.info(`[${requestId}] Promote blocked (${result.blocked})`, { - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - }) - return NextResponse.json(body) - } - - recordAudit({ - workspaceId: auth.targetWorkspaceId, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_PROMOTED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: auth.targetWorkspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: auth.target.name, - description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`, - metadata: { - direction, - sourceWorkspaceId: auth.sourceWorkspaceId, - updated: result.updated, - created: result.created, - archived: result.archived, - redeployed: result.redeployed, - }, - request: req, - }) - - return NextResponse.json(body) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: promoteForkContract, + auth: internalSessionAuth, + operation: forkOperations.sync, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: syncWorkspace, + present: (result) => ({ + promoteRunId: result.promoteRunId, + updated: result.updated, + created: result.created, + archived: result.archived, + redeployed: result.redeployed, + deployFailed: result.deployFailed, + deployWarnings: result.deployWarnings, + unmappedRequired: result.unmappedRequired, + blockers: result.blockers, + needsConfiguration: result.needsConfiguration, + clearedOptional: result.clearedOptional, + droppedReferences: result.droppedReferences, + triggerUrlChanges: result.triggerUrlChanges, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts index d639a56ed27..7d03aa2a6db 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts @@ -1,26 +1,20 @@ -import { db } from '@sim/db' -import { type NextRequest, NextResponse } from 'next/server' import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' -import { listForkCopyableResources } from '@/ee/workspace-forking/lib/mapping/resources' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceForkResourceDetails } from '@/ee/workspace-forking/application/resource-details' -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkResourcesContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - await assertWorkspaceAdminAccess(id, session.user.id) - - const resources = await listForkCopyableResources(db, id) - return NextResponse.json(resources) - } -) +export const GET = defineInternalJsonRoute({ + contract: getForkResourcesContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkResourceDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts index 68575860876..09a3b17ce60 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts @@ -1,92 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { rollbackForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' -import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz' -import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback' - -const logger = createLogger('WorkspaceForkRollbackAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(rollbackForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId } = parsed.data.body - - const target = await assertCanRollback(id, session.user.id) - - const result = await rollbackFork({ - targetWorkspaceId: id, - otherWorkspaceId, - userId: session.user.id, - requestId, - }) - - recordAudit({ - workspaceId: id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_ROLLED_BACK, - resourceType: AuditResourceType.WORKSPACE, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: target.name, - description: `Rolled back the last promote into "${target.name}"`, - metadata: { otherWorkspaceId, ...result }, - request: req, - }) - - // Durable audit entry scoped to this workspace so the undo shows in its Manage Forks - // → Activity log. Non-critical: a failure must not fail the (committed) rollback. - const [other] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, otherWorkspaceId)) - .limit(1) - const otherName = other?.name ?? 'the source workspace' - await recordBackgroundWork(db, { - workspaceId: id, - kind: 'fork_rollback', - status: - result.skipped > 0 || result.pendingActivations.length > 0 - ? 'completed_with_warnings' - : 'completed', - message: - result.pendingActivations.length > 0 - ? `Undid the last sync from "${otherName}" — ${result.pendingActivations.length} deployment(s) still activating` - : `Undid the last sync from "${otherName}"`, - metadata: { - actorName: session.user.name ?? undefined, - otherWorkspaceId, - otherWorkspaceName: otherName, - restored: result.restored, - removed: result.archived, - unarchived: result.unarchived, - skipped: result.skipped, - pendingActivations: result.pendingActivations.length, - }, - }).catch((error) => - logger.error(`[${requestId}] Failed to record rollback activity`, { - error: getErrorMessage(error), - }) - ) - - return NextResponse.json(result) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { rollbackWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineInternalJsonRoute({ + contract: rollbackForkContract, + auth: internalSessionAuth, + operation: forkOperations.rollback, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: rollbackWorkspaceFork, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts index c23e3a45ee9..cff037e493a 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts @@ -8,11 +8,13 @@ * * @vitest-environment node */ +import { user } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FolderCollectionFullError } from '@/lib/folders/errors' -const { mockLogger, mockCreateFork, mockAssertCanFork } = vi.hoisted(() => ({ +const { mockLogger, mockCreateFork, mockAuthorizeWorkspaceOperation } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), @@ -23,7 +25,7 @@ const { mockLogger, mockCreateFork, mockAssertCanFork } = vi.hoisted(() => ({ child: vi.fn(), }, mockCreateFork: vi.fn(), - mockAssertCanFork: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -33,7 +35,25 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/ee/workspace-forking/lib/create-fork', () => ({ createFork: mockCreateFork })) -vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ assertCanFork: mockAssertCanFork })) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, +})) +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: 'Source', + organizationId: null, + allowPersonalApiKeys: true, + })), +})) +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceCreationPolicy: vi.fn(async () => ({ canCreate: true })), +})) import { POST } from '@/app/api/workspaces/[id]/fork/route' @@ -56,11 +76,10 @@ function forkRequest() { describe('POST /api/workspaces/[id]/fork', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER }) - mockAssertCanFork.mockResolvedValue({ - source: { id: SOURCE_WORKSPACE_ID, name: 'Source' }, - policy: {}, - }) + authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER, session: { id: 'session-1' } }) + resetDbChainMock() + queueTableRows(user, [{ name: TEST_USER.name }]) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) }) it('renders a full-folder-tree refusal as an actionable 409', async () => { @@ -100,12 +119,25 @@ describe('POST /api/workspaces/[id]/fork', () => { it('still returns the created fork when the copy succeeds', async () => { mockCreateFork.mockResolvedValue({ - workspace: { id: 'ws-child', name: 'Child' }, + workspace: { + id: 'ws-child', + name: 'Child', + ownerId: TEST_USER.id, + organizationId: null, + workspaceMode: 'personal', + }, workflowsCopied: 2, }) const response = await POST(forkRequest(), routeContext) expect(response.status).toBe(201) + expect(mockCreateFork).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ id: SOURCE_WORKSPACE_ID }), + userId: TEST_USER.id, + actorName: TEST_USER.name, + }) + ) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts index c4a90c30d6f..23c8826b7bc 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts @@ -1,88 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createFork } from '@/ee/workspace-forking/lib/create-fork' -import { assertCanFork } from '@/ee/workspace-forking/lib/lineage/authz' - -const logger = createLogger('WorkspaceForkAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const { id: sourceWorkspaceId } = await context.params - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id) - - const parsed = await parseRequest(forkWorkspaceContract, req, context) - if (!parsed.success) return parsed.response - - const copy = parsed.data.body.copy - let result: Awaited> - try { - result = await createFork({ - source, - policy, - userId: session.user.id, - actorName: session.user.name ?? undefined, - name: parsed.data.body.name, - selection: { - files: copy?.files ?? [], - tables: copy?.tables ?? [], - knowledgeBases: copy?.knowledgeBases ?? [], - customTools: copy?.customTools ?? [], - skills: copy?.skills ?? [], - mcpServers: copy?.mcpServers ?? [], - workflowMcpServers: copy?.workflowMcpServers ?? [], - }, - requestId, - }) - } catch (error) { - /** - * The fork copy raises classified, caller-fixable refusals — the child workspace's - * folder ceiling being full, for one. Without this branch they reach - * `withRouteHandler`, which only understands `HttpError` and renders everything else - * as an opaque `Internal server error` 500, dropping the message that tells the user - * what to do. Unwrapped from the cause chain because drizzle re-wraps anything thrown - * inside a transaction callback in a `DrizzleQueryError`. - */ - const classified = asOrchestrationError(error) - if (!classified) throw error - logger.warn(`[${requestId}] Fork of ${sourceWorkspaceId} refused: ${classified.message}`) - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - - recordAudit({ - workspaceId: result.workspace.id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: result.workspace.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.workspace.name, - description: `Forked workspace from "${source.name}"`, - metadata: { - parentWorkspaceId: source.id, - workflowsCopied: result.workflowsCopied, - }, - request: req, - }) - - logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`) - return NextResponse.json(result, { status: 201 }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: forkWorkspaceContract, + auth: internalSessionAuth, + operation: forkOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: forkWorkspace, + present: (result) => ({ workspace: result.workspace, workflowsCopied: result.workflowsCopied }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts index d779547ef12..254c7b81975 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts @@ -1,49 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type NextRequest, NextResponse } from 'next/server' import { unlinkForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz' -import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { unlinkWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(unlinkForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId } = parsed.data.body - - const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id) - const result = await unlinkForkEdge(edge, requestId) - - if (result.unlinked) { - recordAudit({ - workspaceId: id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_UNLINKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: current.name, - description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`, - metadata: { - otherWorkspaceId, - childWorkspaceId: edge.childWorkspaceId, - parentWorkspaceId: edge.parentWorkspaceId, - }, - request: req, - }) - } - - return NextResponse.json(result) - } -) +export const POST = defineInternalJsonRoute({ + contract: unlinkForkContract, + auth: internalSessionAuth, + operation: forkOperations.unlink, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: unlinkWorkspaceFork, + present: (result) => result, +}) diff --git a/apps/sim/blocks/blocks/mcp.ts b/apps/sim/blocks/blocks/mcp.ts index ec3c9acaa46..f20a79a7fdc 100644 --- a/apps/sim/blocks/blocks/mcp.ts +++ b/apps/sim/blocks/blocks/mcp.ts @@ -39,6 +39,7 @@ export const McpBlock: BlockConfig = { id: 'tool', title: 'Tool', type: 'mcp-tool-selector', + selectorKey: 'mcp.tools', required: true, placeholder: 'Select a tool', description: 'Available tools from the selected MCP server', diff --git a/apps/sim/ee/workspace-forking/api/route-policies.ts b/apps/sim/ee/workspace-forking/api/route-policies.ts new file mode 100644 index 00000000000..313e46c70fe --- /dev/null +++ b/apps/sim/ee/workspace-forking/api/route-policies.ts @@ -0,0 +1,49 @@ +import { + createV2ResourceConcealmentPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { WorkspaceOperationConflict } from '@/lib/workspaces/operations/receipts' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' +import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' + +export const v2ForkErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + render(error) { + const classified = asOrchestrationError(error) + if (classified instanceof WorkspaceOperationConflict) + return v2ErrorForOrchestration(classified.code, classified.message, classified.details) + if (error instanceof ForkError) { + if (error.statusCode === 404) return v2Error('NOT_FOUND', error.message) + if (error.statusCode === 409) + return v2Error('CONFLICT', error.message, { details: { applied: false } }) + if (error.statusCode === 413) return v2Error('PAYLOAD_TOO_LARGE', error.message) + if (error.statusCode === 403) + return v2Error('FORBIDDEN', error.message, { + details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' }, + }) + if (error.statusCode === 400) return v2Error('BAD_REQUEST', error.message) + } + return v2CaughtOrchestrationError(error) + }, +}) + +export const internalForkErrorPolicy: InternalErrorPolicy = { + project(error) { + if (error instanceof ForkError) + return internalErrorResponse(error.statusCode, { error: error.message }) + const classified = asOrchestrationError(error) + if (classified) + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + return internalOrchestrationErrorPolicy.project(error) + }, + unhandled: internalOrchestrationErrorPolicy.unhandled, +} diff --git a/apps/sim/ee/workspace-forking/application/admit-sync.ts b/apps/sim/ee/workspace-forking/application/admit-sync.ts new file mode 100644 index 00000000000..243d3332609 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/admit-sync.ts @@ -0,0 +1,126 @@ +import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import type { DbOrTx } from '@/lib/db/types' +import { prepareWorkflowSnapshotDeployment } from '@/lib/workflows/orchestration/deploy' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' +import { + insertWorkspaceOperationReceipt, + type WorkspaceOperationReport, +} from '@/lib/workspaces/operations/receipts' +import { enqueueDurableForkContent } from '@/ee/workspace-forking/application/content-outbox' +import type { ForkMutationAdmission } from '@/ee/workspace-forking/application/revision' +import { + type ForkContentCopyPayload, + hasForkContentToCopy, +} from '@/ee/workspace-forking/lib/copy/content-copy-runner' +import type { PromoteForkResult } from '@/ee/workspace-forking/lib/promote/promote' + +/** Persists the receipt, exact deployment versions, and resumable effects with the sync writes. */ +export async function admitForkSync( + tx: DbOrTx, + params: { + admission: ForkMutationAdmission + targetWorkspaceId: string + direction: 'push' | 'pull' + userId: string + result: Omit + targetIds: string[] + undeployEventIds: string[] + mcpAttachmentServerIds: string[] + needsConfigurationIds: Set + copy?: ForkContentCopyPayload + } +): Promise { + const { admission, result } = params + const report: WorkspaceOperationReport = { + operationId: generateId(), + requestId: admission.requestId, + workspaceId: admission.workspaceId, + kind: params.direction === 'push' ? 'workspace_push' : 'workspace_pull', + applied: true, + status: 'processing', + resourceIds: params.targetIds, + deploymentOperationIds: [], + issues: [], + syncResult: result, + effectEventIds: [...params.undeployEventIds], + triggerUrlChanges: result.triggerUrlChanges, + } + for (const workflowId of params.needsConfigurationIds) + report.issues.push({ + code: 'required_configuration', + workflowId, + message: 'Required dependent fields need destination configuration before deployment', + }) + for (const entry of result.clearedOptional) + report.issues.push({ + code: 'optional_configuration_cleared', + message: truncate(`${entry.workflowName}: ${entry.blocks.join(', ')}`, 2048), + }) + if (result.triggerUrlChanges.length) + report.issues.push({ + code: 'trigger_url_changed', + message: `${result.triggerUrlChanges.length} trigger URLs changed; inspect triggerUrlChanges before using this environment`, + }) + if (result.droppedReferences.length) + report.issues.push({ + code: 'references_dropped', + message: `${result.droppedReferences.length} source-deleted references were explicitly cleared`, + }) + if (params.copy && hasForkContentToCopy(params.copy.contentPlan, params.copy.blobTasks)) { + report.copyProgress = { status: 'pending', copied: 0, failed: 0 } + report.contentOutboxEventId = await enqueueDurableForkContent(tx, report, params.copy) + } + for (const workflowId of [...params.targetIds].sort()) { + if (params.needsConfigurationIds.has(workflowId)) continue + const workflowState = await loadWorkflowDeploymentSnapshot(workflowId, tx) + if (!workflowState) throw new Error('A synced workflow is missing its admitted graph') + const prepared = await prepareWorkflowSnapshotDeployment({ + params: { workflowId, userId: params.userId, requestId: admission.requestId }, + actorId: params.userId, + requestId: admission.requestId, + idempotencyKey: `${report.operationId}:${workflowId}`, + workflowState, + tx, + workspaceOperationId: report.operationId, + }) + if (prepared.success) { + report.deploymentOperationIds!.push(prepared.operation.id) + if (prepared.outboxEventId) report.effectEventIds!.push(prepared.outboxEventId) + } else + report.issues.push({ + code: 'deployment_admission_failed', + workflowId, + message: prepared.error, + }) + } + if (params.mcpAttachmentServerIds.length) + report.effectEventIds!.push( + await enqueueOutboxEvent(tx, 'workspace.mcp.changed', { + serverIds: params.mcpAttachmentServerIds, + }) + ) + if ( + !report.deploymentOperationIds!.length && + !report.copyProgress && + !report.effectEventIds!.length + ) { + report.status = report.issues.some((issue) => issue.code === 'deployment_admission_failed') + ? 'failed' + : params.needsConfigurationIds.size + ? 'requires_configuration' + : report.issues.length + ? 'completed_with_warnings' + : 'completed' + } + await enqueueOutboxEvent(tx, 'workspace.workflows.changed', { + workspaceId: params.targetWorkspaceId, + }) + await enqueueOutboxEvent(tx, 'workspace.operation.observe', { + workspaceId: report.workspaceId, + operationId: report.operationId, + }) + await insertWorkspaceOperationReceipt(tx, admission.requestHash, report) + return report +} diff --git a/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts b/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts new file mode 100644 index 00000000000..775fd3b426a --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts @@ -0,0 +1,72 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, +} from '@/lib/core/application/authorized-workspace-use-case' +import { + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application/workspace-authorization' +import type { WorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getWorkspaceWithOwner, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { assertForkingEnabled } from '@/ee/workspace-forking/lib/lineage/authz' +import { type ForkEdge, resolveForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' + +export interface ForkApplicationContext extends WorkspaceAuthorizationContext { + workspace: WorkspaceWithOwner + other?: WorkspaceWithOwner + edge?: ForkEdge +} + +/** Adapters share the same current-principal checks on each side of a canonical fork edge. */ +export function defineForkUseCase< + const O extends WorkspaceOperation, + I extends { workspaceId: string; otherWorkspaceId?: string }, + R, +>( + definition: Pick< + AuthorizedWorkspaceUseCaseDefinition, + 'operation' | 'execute' | 'projectAudit' | 'afterSuccess' + > & { bothSides?: boolean; edge?: boolean; availability?: boolean } +) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: {}, + async resolveContext({ input }) { + const workspace = await getWorkspaceWithOwner(input.workspaceId, { includeArchived: false }) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { + workspace, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + } + }, + async authorizeResource({ principal, input, context }) { + if (!definition.availability) await assertForkingEnabled(context.workspace.organizationId) + if (definition.bothSides) { + if (!input.otherWorkspaceId) + throw new OrchestrationError('validation', 'otherWorkspaceId is required') + const other = await getWorkspaceWithOwner(input.otherWorkspaceId, { + includeArchived: false, + }) + if (!other) throw new OrchestrationError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, definition.operation, { + workspaceId: other.id, + workspaceOrganizationId: other.organizationId, + allowPersonalApiKeys: other.allowPersonalApiKeys, + }) + await assertForkingEnabled(other.organizationId) + context.other = other + } + if (definition.edge) { + if (!input.otherWorkspaceId) + throw new OrchestrationError('validation', 'otherWorkspaceId is required') + const edge = await resolveForkEdge(context.workspaceId, input.otherWorkspaceId) + if (!edge) + throw new OrchestrationError('validation', 'These workspaces are not a direct fork edge') + context.edge = edge + } + }, + }) +} diff --git a/apps/sim/ee/workspace-forking/application/content-outbox.test.ts b/apps/sim/ee/workspace-forking/application/content-outbox.test.ts new file mode 100644 index 00000000000..aa676de56d8 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/content-outbox.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OutboxEventContext } from '@/lib/core/outbox/service' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' +import type { + ForkContentCopyPayload, + runForkContentCopy, +} from '@/ee/workspace-forking/lib/copy/content-copy-runner' +import { + ForkCopyCheckpointError, + ForkCopyContinuation, + type ForkCopyControl, + type ForkCopyProgress, +} from '@/ee/workspace-forking/lib/copy/progress' + +const { mockRunForkContentCopy, receiptTable } = vi.hoisted(() => ({ + mockRunForkContentCopy: vi.fn(), + receiptTable: { + id: 'workspaceOperationReceipt.id', + workspaceId: 'workspaceOperationReceipt.workspaceId', + report: 'workspaceOperationReceipt.report', + }, +})) + +vi.mock('@sim/db/schema', () => ({ ...schemaMock, workspaceOperationReceipt: receiptTable })) +vi.mock('@/ee/workspace-forking/lib/copy/content-copy-runner', () => ({ + runForkContentCopy: mockRunForkContentCopy, +})) + +const copy: ForkContentCopyPayload = { + contentPlan: { + sourceWorkspaceId: 'source', + childWorkspaceId: 'target', + userId: 'user', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + blobTasks: [], +} +const payload = { operationId: 'operation', workspaceId: 'target', copy } +const handler = forkContentOutboxHandlers['workspace.fork.content.copy'] + +function context(overrides: Partial = {}): OutboxEventContext { + return { + eventId: 'copy-event', + eventType: 'workspace.fork.content.copy', + attempts: 0, + maxAttempts: 10, + signal: new AbortController().signal, + checkpointPayload: vi.fn(async () => {}), + ...overrides, + } +} + +function report(overrides: Partial = {}): WorkspaceOperationReport { + return { + operationId: 'operation', + requestId: 'request', + workspaceId: 'target', + kind: 'workspace_fork', + applied: true, + status: 'processing', + resourceIds: ['target'], + issues: [], + copyProgress: { status: 'pending', copied: 0, failed: 0 }, + ...overrides, + } +} + +function copyControl(options: Parameters[1]): ForkCopyControl & { + progress: ForkCopyProgress + checkpoint: (progress: ForkCopyProgress) => Promise +} { + if (!options?.control?.progress || !options.control.checkpoint) throw new Error('Missing control') + return { + ...options.control, + progress: options.control.progress, + checkpoint: options.control.checkpoint, + } +} + +describe('fork content outbox checkpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockRunForkContentCopy.mockReset() + }) + + it('writes concurrent progress snapshots in order without later mutations changing earlier writes', async () => { + queueTableRows(receiptTable, [{ report: report() }]) + let releaseFirst = () => {} + const firstWrite = new Promise((resolve) => { + releaseFirst = resolve + }) + const checkpoint = vi.fn(async () => {}) + checkpoint.mockImplementationOnce(() => firstWrite) + mockRunForkContentCopy.mockImplementation(async (_, options) => { + const control = copyControl(options) + control.progress.tables.first = { afterId: '8', copied: 8, lastOrderKey: null } + const first = control.checkpoint(control.progress) + control.progress.tables.second = { afterId: '4', copied: 4, lastOrderKey: null } + const second = control.checkpoint(control.progress) + await Promise.resolve() + expect(checkpoint).toHaveBeenCalledTimes(1) + expect(checkpoint).toHaveBeenNthCalledWith(1, { + progress: { + completed: [], + tables: { first: { afterId: '8', copied: 8, lastOrderKey: null } }, + embeddings: {}, + }, + }) + releaseFirst() + await Promise.all([first, second]) + expect(checkpoint).toHaveBeenNthCalledWith(2, { progress: control.progress }) + }) + await handler(payload, context({ checkpointPayload: checkpoint })) + }) + + it('rejects every queued checkpoint after lease loss and prevents stale writes', async () => { + queueTableRows(receiptTable, [{ report: report() }]) + const checkpoint = vi.fn(async () => { + throw new Error('lease no longer held') + }) + mockRunForkContentCopy.mockImplementation(async (_, options) => { + const control = copyControl(options) + const outcomes = await Promise.allSettled([ + control.checkpoint(control.progress), + control.checkpoint(control.progress), + ]) + for (const outcome of outcomes) { + expect(outcome.status).toBe('rejected') + if (outcome.status === 'rejected') + expect(outcome.reason).toBeInstanceOf(ForkCopyCheckpointError) + } + if (outcomes[0].status === 'rejected') throw outcomes[0].reason + }) + await expect( + handler(payload, context({ checkpointPayload: checkpoint })) + ).rejects.toBeInstanceOf(ForkCopyCheckpointError) + expect(checkpoint).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('returns a continuation without consuming a retry or recording completion', async () => { + queueTableRows(receiptTable, [{ report: report() }]) + mockRunForkContentCopy.mockRejectedValueOnce(new ForkCopyContinuation('resume from cursor')) + expect(await handler(payload, context())).toMatchObject({ + outcome: 'deferred', + consumeAttempt: false, + reason: 'resume from cursor', + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('retains a committed copy failure while remaining processing for admitted effects', async () => { + const current = report({ effectEventIds: ['deployment-effect'] }) + queueTableRows(receiptTable, [{ report: current }]) + queueTableRows(receiptTable, [{ report: current }]) + mockRunForkContentCopy.mockImplementation(async (_, options) => { + await options?.onComplete?.({ copied: 2, failed: 1 }) + }) + await handler(payload, context()) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ + report: expect.objectContaining({ + applied: true, + status: 'processing', + copyProgress: { status: 'failed', copied: 2, failed: 1 }, + issues: [expect.objectContaining({ code: 'resource_copy_failed' })], + }), + updatedAt: expect.any(Date), + }) + }) + + it('does not overwrite the receipt when the completing worker loses its lease', async () => { + queueTableRows(receiptTable, [{ report: report() }]) + const controller = new AbortController() + mockRunForkContentCopy.mockImplementation(async (_, options) => { + controller.abort(new Error('lease expired')) + await options?.onComplete?.({ copied: 1, failed: 0 }) + }) + await expect(handler(payload, context({ signal: controller.signal }))).rejects.toThrow( + 'lease expired' + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('does not rerun an already completed receipt', async () => { + queueTableRows(receiptTable, [ + { report: report({ copyProgress: { status: 'completed', copied: 1, failed: 0 } }) }, + ]) + await handler(payload, context()) + expect(mockRunForkContentCopy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/content-outbox.ts b/apps/sim/ee/workspace-forking/application/content-outbox.ts new file mode 100644 index 00000000000..be7e28df692 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/content-outbox.ts @@ -0,0 +1,251 @@ +import { db } from '@sim/db' +import { workspaceOperationReceipt } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + continueOutboxHandler, + enqueueOutboxEvent, + type OutboxHandlerRegistry, + withOutboxHandlerTimeout, +} from '@/lib/core/outbox/service' +import type { DbOrTx } from '@/lib/db/types' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { + type ForkContentCopyPayload, + runForkContentCopy, +} from '@/ee/workspace-forking/lib/copy/content-copy-runner' +import { + ForkCopyCheckpointError, + ForkCopyContinuation, +} from '@/ee/workspace-forking/lib/copy/progress' + +const id = z.string().min(1).max(4096) +const identityMap = z.record(id, id) +const pair = z.object({ sourceId: id, childId: id }).strict() +const contentPayloadSchema = z + .object({ + operationId: id, + workspaceId: id, + copyFinished: z.boolean().optional(), + progress: z + .object({ + completed: z.array(id).max(20000), + tables: z.record( + id, + z + .object({ + afterId: id, + copied: z.number().int().min(0), + lastOrderKey: z.string().max(256).nullable(), + }) + .strict() + ), + embeddings: z.record( + id, + z + .object({ + afterId: id.nullable(), + sourceRevision: z.string().length(64), + knowledgeBaseId: id, + }) + .strict() + ), + }) + .strict() + .optional(), + copy: z + .object({ + contentPlan: z + .object({ + sourceWorkspaceId: id, + childWorkspaceId: id, + userId: id, + tables: z.array(pair).max(2000), + knowledgeBases: z.array(pair.extend({ documentIdMap: identityMap })).max(2000), + skills: z.array(z.object({ childId: id }).strict()).max(2000), + documents: z + .array( + z + .object({ + sourceDocId: id, + childDocId: id, + childKnowledgeBaseId: id, + storageKey: id.nullable(), + fileUrl: z.string().max(16384), + fileSize: z.number().min(0), + filename: z.string().max(4096), + mimeType: z.string().max(1024), + }) + .strict() + ) + .max(10000), + documentMappingContext: z + .object({ edgeChildWorkspaceId: id, sourceIsParent: z.boolean() }) + .strict() + .optional(), + }) + .strict(), + blobTasks: z + .array( + z + .object({ + sourceFileId: id.optional(), + sourceContentUpdatedAtMs: z.number().optional(), + sourceKey: id, + targetKey: id, + context: z.enum([ + 'knowledge-base', + 'chat', + 'copilot', + 'mothership', + 'execution', + 'workspace', + 'table-import', + 'profile-pictures', + 'og-images', + 'logs', + 'workspace-logos', + ]), + fileName: z.string().max(4096), + contentType: z.string().max(1024), + size: z.number().min(0), + targetFileId: id, + displayName: z.string().max(4096).nullable(), + userId: id, + workspaceId: id, + targetFolderId: id.nullable().optional(), + }) + .strict() + ) + .max(2000), + contentRefMaps: z + .object({ + workspaceId: z.object({ from: id, to: id }).strict().optional(), + fileKeys: identityMap.optional(), + fileIds: identityMap.optional(), + workflows: identityMap.optional(), + knowledgeBases: identityMap.optional(), + tables: identityMap.optional(), + skills: identityMap.optional(), + folders: identityMap.optional(), + }) + .strict() + .optional(), + statusId: id.optional(), + completionStatus: z.enum(['completed', 'completed_with_warnings']).optional(), + deployedTargetWorkflowIds: z.array(id).max(1000).optional(), + requestId: id.optional(), + }) + .strict(), + }) + .strict() + +export async function enqueueDurableForkContent( + tx: DbOrTx, + report: WorkspaceOperationReport, + copy: ForkContentCopyPayload +): Promise { + const payload = contentPayloadSchema.parse({ + operationId: report.operationId, + workspaceId: report.workspaceId, + copy, + }) + if (Buffer.byteLength(JSON.stringify(payload)) > 8 * 1024 * 1024) + throw new OrchestrationError('payload_too_large', 'Fork background work exceeds 8 MiB') + return enqueueOutboxEvent(tx, 'workspace.fork.content.copy', payload) +} + +export const forkContentOutboxHandlers = { + 'workspace.fork.content.copy': withOutboxHandlerTimeout(async (raw, context) => { + const payload = contentPayloadSchema.parse(raw) + const [receipt] = await db + .select({ report: workspaceOperationReceipt.report }) + .from(workspaceOperationReceipt) + .where( + and( + eq(workspaceOperationReceipt.id, payload.operationId), + eq(workspaceOperationReceipt.workspaceId, payload.workspaceId) + ) + ) + .limit(1) + if (!receipt) return + const report = receipt.report as WorkspaceOperationReport + if (report.copyProgress?.status !== 'pending') return + let checkpointTail = Promise.resolve() + try { + await runForkContentCopy(payload.copy, { + preserveSnapshots: true, + signal: context.signal, + control: { + signal: context.signal, + deadlineAt: context.deadlineAt, + progress: payload.progress ?? { completed: [], tables: {}, embeddings: {} }, + checkpoint: async (progress) => { + context.signal.throwIfAborted() + if (Buffer.byteLength(JSON.stringify(progress)) > 2 * 1024 * 1024) + throw new ForkCopyCheckpointError('Copy checkpoint exceeds 2 MiB') + const snapshot = structuredClone(progress) + checkpointTail = checkpointTail.then(async () => { + context.signal.throwIfAborted() + try { + await context.checkpointPayload({ progress: snapshot }) + } catch (error) { + throw new ForkCopyCheckpointError('Could not retain the copy checkpoint', { + cause: error, + }) + } + }) + await checkpointTail + }, + }, + onComplete: async ({ copied, failed }) => { + context.signal.throwIfAborted() + await context.checkpointPayload({ copyFinished: true }) + await db.transaction(async (tx) => { + const [current] = await tx + .select({ report: workspaceOperationReceipt.report }) + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.id, payload.operationId)) + .for('update') + .limit(1) + context.signal.throwIfAborted() + if (!current) return + const report = current.report as WorkspaceOperationReport + if (report.copyProgress?.status !== 'pending') return + const updated: WorkspaceOperationReport = { + ...report, + copyProgress: { status: failed ? 'failed' : 'completed', copied, failed }, + status: + report.deploymentOperationIds?.length || report.effectEventIds?.length + ? 'processing' + : failed + ? 'failed' + : report.issues.some((issue) => issue.code === 'required_configuration') + ? 'requires_configuration' + : report.issues.length + ? 'completed_with_warnings' + : 'completed', + issues: failed + ? [ + ...report.issues, + { + code: 'resource_copy_failed', + message: `${failed} selected resources could not be copied; the workspace changes remain committed`, + }, + ] + : report.issues, + } + await tx + .update(workspaceOperationReceipt) + .set({ report: updated, updatedAt: new Date() }) + .where(eq(workspaceOperationReceipt.id, payload.operationId)) + }) + }, + }) + } catch (error) { + if (error instanceof ForkCopyContinuation) return continueOutboxHandler(error.message, 1000) + throw error + } + }, 550000), +} satisfies OutboxHandlerRegistry diff --git a/apps/sim/ee/workspace-forking/application/create-and-sync.ts b/apps/sim/ee/workspace-forking/application/create-and-sync.ts new file mode 100644 index 00000000000..cae65289e96 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/create-and-sync.ts @@ -0,0 +1,296 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + withWorkspaceOperationReplay, + workflowOperationFingerprint, +} from '@/lib/workspaces/operations/receipts' +import { getWorkspaceCreationPolicy } from '@/lib/workspaces/policy' +import { + defineForkUseCase, + type ForkApplicationContext, +} from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { previewForkSync, type SyncChoices } from '@/ee/workspace-forking/application/preview-sync' +import { loadForkPreviewRevision } from '@/ee/workspace-forking/application/revision' +import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { createFork, type ForkResourceSelection } from '@/ee/workspace-forking/lib/create-fork' +import { type PromoteForkParams, promoteFork } from '@/ee/workspace-forking/lib/promote/promote' + +export interface ForkInput { + workspaceId: string + name?: string + copy?: Partial + requestId?: string + previewFingerprint?: string +} +export interface SyncInput extends SyncChoices { + dependentValues?: PromoteForkParams['dependentValues'] + workspaceId: string + otherWorkspaceId: string + direction: 'push' | 'pull' + requestId?: string + previewFingerprint?: string +} + +async function loadActorName(userId: string): Promise { + const [actor] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + return actor?.name ?? undefined +} + +function forkChoices(input: ForkInput) { + return { + name: input.name, + copy: input.copy + ? { + files: input.copy.files ?? [], + tables: input.copy.tables ?? [], + knowledgeBases: input.copy.knowledgeBases ?? [], + customTools: input.copy.customTools ?? [], + skills: input.copy.skills ?? [], + mcpServers: input.copy.mcpServers ?? [], + workflowMcpServers: input.copy.workflowMcpServers ?? [], + } + : undefined, + } +} + +function syncChoices(input: SyncInput) { + return { + otherWorkspaceId: input.otherWorkspaceId, + direction: input.direction, + mappings: input.mappings, + sourceDependentValues: input.sourceDependentValues, + copyResources: input.copyResources, + dropReferences: input.dropReferences, + triggerMappings: input.triggerMappings, + } +} + +function syncContext(input: SyncInput, context: ForkApplicationContext) { + if (!context.edge || !context.other) throw new Error('Sync requires an authorized fork edge') + return { + edge: context.edge, + sourceWorkspaceId: input.direction === 'push' ? context.workspace.id : context.other.id, + targetWorkspaceId: input.direction === 'push' ? context.other.id : context.workspace.id, + direction: input.direction, + } +} + +async function creationPolicy(workspace: ForkApplicationContext['workspace'], userId: string) { + const policy = await getWorkspaceCreationPolicy({ + userId, + activeOrganizationId: workspace.organizationId, + pinOrganization: true, + }) + if (!policy.canCreate) + throw new OrchestrationError( + policy.status === 403 ? 'forbidden' : 'validation', + policy.reason ?? 'Workspace creation is not permitted' + ) + return policy +} + +export const previewWorkspaceFork = defineForkUseCase({ + operation: forkOperations.preview, + async execute({ + principal, + input, + context, + }: { + principal: { userId: string } + input: ForkInput + context: ForkApplicationContext + }) { + await creationPolicy(context.workspace, principal.userId) + const revision = await loadForkPreviewRevision( + db, + { sourceWorkspaceId: context.workspaceId }, + forkChoices(input) + ) + const { deployedWorkflows } = await loadSourceDeployedStates(context.workspaceId) + return { + previewFingerprint: revision.fingerprint, + sourceWorkspaceId: context.workspaceId, + workflows: deployedWorkflows.map((item) => ({ sourceWorkflowId: item.id, name: item.name })), + selectedResourceCount: Object.values(input.copy ?? {}).reduce( + (count, ids) => count + (ids?.length ?? 0), + 0 + ), + draftOnly: true as const, + } + }, +}) + +export const forkWorkspace = defineForkUseCase< + typeof forkOperations.create, + ForkInput, + Awaited> +>({ + operation: forkOperations.create, + async execute({ principal, input, context, request }) { + const choices = forkChoices(input) + const admission = + input.requestId && input.previewFingerprint + ? { + workspaceId: context.workspaceId, + requestId: input.requestId, + previewFingerprint: input.previewFingerprint, + choices, + requestHash: workflowOperationFingerprint({ + operation: 'workspace_fork', + workspaceId: context.workspaceId, + choices, + previewFingerprint: input.previewFingerprint, + }), + } + : undefined + if ((input.requestId || input.previewFingerprint) && !admission) + throw new OrchestrationError( + 'validation', + 'requestId and previewFingerprint are required together' + ) + const apply = async () => { + const policy = await creationPolicy(context.workspace, principal.userId) + return createFork({ + source: context.workspace, + policy, + userId: principal.userId, + actorName: await loadActorName(principal.userId), + name: input.name, + selection: choices.copy, + requestId: input.requestId ?? request?.headers.get('x-request-id') ?? generateShortId(), + admission, + }) + } + return admission + ? withWorkspaceOperationReplay( + admission, + (receipt) => { + if (!receipt.forkResult) + throw new OrchestrationError('internal', 'Fork receipt is missing its result') + return { ...receipt.forkResult, operation: receipt, replayed: true } + }, + apply + ) + : apply() + }, + projectAudit: ({ context, result }) => + result.replayed + ? [] + : { + workspaceId: result.workspace.id, + action: AuditAction.WORKSPACE_FORKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: result.workspace.id, + resourceName: result.workspace.name, + description: `Forked workspace from "${context.workspace.name}"`, + metadata: { + parentWorkspaceId: context.workspaceId, + workflowsCopied: result.workflowsCopied, + }, + }, +}) + +export const previewWorkspaceSync = defineForkUseCase({ + operation: forkOperations.syncPreview, + bothSides: true, + edge: true, + execute: ({ + input, + context, + principal, + }: { + input: SyncInput + context: ForkApplicationContext + principal: Principal + }) => + previewForkSync( + { ...syncContext(input, context), ...syncChoices(input) }, + syncChoices(input), + principal + ), +}) + +export const syncWorkspace = defineForkUseCase< + typeof forkOperations.sync, + SyncInput, + Awaited> +>({ + operation: forkOperations.sync, + bothSides: true, + edge: true, + async execute({ principal, input, context, request }) { + const choices = syncChoices(input) + const admission = + input.requestId && input.previewFingerprint + ? { + workspaceId: context.workspaceId, + requestId: input.requestId, + previewFingerprint: input.previewFingerprint, + choices, + requestHash: workflowOperationFingerprint({ + operation: 'workspace_sync', + workspaceId: context.workspaceId, + choices, + previewFingerprint: input.previewFingerprint, + }), + } + : undefined + if ((input.requestId || input.previewFingerprint) && !admission) + throw new OrchestrationError( + 'validation', + 'requestId and previewFingerprint are required together' + ) + const apply = async () => { + if (admission) + await previewForkSync({ ...syncContext(input, context), ...choices }, choices, principal) + return promoteFork({ + ...syncContext(input, context), + ...choices, + dependentValues: input.dependentValues, + userId: principal.userId, + actorName: await loadActorName(principal.userId), + otherWorkspaceName: context.other!.name, + requestId: input.requestId ?? request?.headers.get('x-request-id') ?? generateShortId(), + admission, + }) + } + return admission + ? withWorkspaceOperationReplay( + admission, + (receipt) => { + if (!receipt.syncResult) + throw new OrchestrationError('internal', 'Sync receipt is missing its result') + return { ...receipt.syncResult, operation: receipt, replayed: true } + }, + apply + ) + : apply() + }, + projectAudit: ({ input, context, result }) => + result.replayed || result.blocked + ? [] + : { + workspaceId: syncContext(input, context).targetWorkspaceId, + action: AuditAction.WORKSPACE_FORK_PROMOTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: syncContext(input, context).targetWorkspaceId, + resourceName: input.direction === 'push' ? context.other!.name : context.workspace.name, + metadata: { + otherWorkspaceId: input.otherWorkspaceId, + direction: input.direction, + updated: result.updated, + created: result.created, + archived: result.archived, + }, + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/discovery.test.ts b/apps/sim/ee/workspace-forking/application/discovery.test.ts new file mode 100644 index 00000000000..d89e2d99173 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/discovery.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { workspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: vi.fn(), + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: 'Parent', + organizationId: null, + allowPersonalApiKeys: true, + })), +})) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + assertForkingEnabled: vi.fn(), + isForkingAvailableForWorkspace: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ + getForkParent: vi.fn(), + resolveForkEdge: vi.fn(), +})) +vi.mock('@/lib/workflows/references/resources', () => ({ + listForkCopyableResourcePage: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ + resourceTypeToForkKind: vi.fn(), +})) + +import { listWorkspaceForkChildren } from '@/ee/workspace-forking/application/discovery' + +const principal: SessionPrincipal = { kind: 'session', userId: 'actor-1', sessionId: 'session-1' } +const pageInput = { workspaceId: 'parent', limit: 1, sortBy: 'createdAt', sortOrder: 'desc' } +const timestamp = '2026-09-09 12:34:56.123456' +const child = { + id: 'child-z', + name: 'Child', + organizationId: null, + createdAt: new Date('2026-09-09T12:34:56.123Z'), + cursorCreatedAt: timestamp, +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) + +describe('fork child pagination', () => { + it('keeps database microseconds in the cursor and binds them unchanged on the next page', async () => { + queueTableRows(workspace, [child, { ...child, id: 'child-y' }]) + const first = await listWorkspaceForkChildren.execute({ principal, input: pageInput }) + + expect(first.items).toEqual([ + { + id: 'child-z', + name: 'Child', + organizationId: null, + createdAt: '2026-09-09T12:34:56.123Z', + }, + ]) + expect(first.nextCursor).not.toBeNull() + expect(JSON.parse(Buffer.from(first.nextCursor!, 'base64url').toString('utf8'))).toMatchObject({ + id: 'child-z', + createdAt: timestamp, + }) + expect(dbChainMockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + cursorCreatedAt: expect.objectContaining({ + strings: expect.arrayContaining(['', '::text']), + }), + }) + ) + + queueTableRows(workspace, [{ ...child, id: 'child-y' }]) + const second = await listWorkspaceForkChildren.execute({ + principal, + input: { ...pageInput, cursor: first.nextCursor! }, + }) + + expect(second.items.map((item) => item.id)).toEqual(['child-y']) + expect(second.nextCursor).toBeNull() + expect(dbChainMockFns.where).toHaveBeenLastCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ + strings: expect.arrayContaining(['::timestamp, ']), + values: expect.arrayContaining([timestamp, 'child-z']), + }), + ]), + }) + ) + }) + + it.each([{ workspaceId: 'another-parent' }, { sortOrder: 'asc' }])( + 'refuses a cursor reused under a different scope %s', + async (change) => { + queueTableRows(workspace, [child, { ...child, id: 'child-y' }]) + const first = await listWorkspaceForkChildren.execute({ principal, input: pageInput }) + const queriesBefore = dbChainMockFns.select.mock.calls.length + + await expect( + listWorkspaceForkChildren.execute({ + principal, + input: { ...pageInput, ...change, cursor: first.nextCursor! }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(queriesBefore) + } + ) + + it('rejects a malformed timestamp before querying children', async () => { + queueTableRows(workspace, [child, { ...child, id: 'child-y' }]) + const first = await listWorkspaceForkChildren.execute({ principal, input: pageInput }) + const decoded = JSON.parse(Buffer.from(first.nextCursor!, 'base64url').toString('utf8')) + decoded.createdAt = 'not-a-timestamp' + const cursor = Buffer.from(JSON.stringify(decoded)).toString('base64url') + const queriesBefore = dbChainMockFns.select.mock.calls.length + + await expect( + listWorkspaceForkChildren.execute({ + principal, + input: { ...pageInput, cursor }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(queriesBefore) + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/discovery.ts b/apps/sim/ee/workspace-forking/application/discovery.ts new file mode 100644 index 00000000000..d4e4b8050ce --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/discovery.ts @@ -0,0 +1,228 @@ +import { db } from '@sim/db' +import { workspace, workspaceForkResourceMap } from '@sim/db/schema' +import { and, asc, desc, eq, gt, isNull, sql } from 'drizzle-orm' +import { z } from 'zod' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ForkCopyableResources, + listForkCopyableResourcePage, +} from '@/lib/workflows/references/resources' +import { workflowOperationFingerprint } from '@/lib/workspaces/operations/receipts' +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' +import { resourceTypeToForkKind } from '@/ee/workspace-forking/lib/mapping/mapping-store' + +interface WorkspaceInput { + workspaceId: string +} +interface PageInput extends WorkspaceInput { + limit: number + cursor?: string + sortBy: string + sortOrder: string +} +const cursorSchema = z + .object({ + id: z.string().min(1).max(4096), + createdAt: z + .string() + .max(64) + .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$/) + .optional(), + scope: z.string().length(64), + }) + .strict() + +function readCursor(input: PageInput, filters: Record) { + const scope = workflowOperationFingerprint({ + workspaceId: input.workspaceId, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + ...filters, + }) + if (!input.cursor) return { scope, id: undefined, createdAt: undefined } + try { + const value = cursorSchema.parse( + JSON.parse(Buffer.from(input.cursor, 'base64url').toString('utf8')) + ) + if (value.scope !== scope) throw new Error('Cursor scope mismatch') + return value + } catch { + throw new OrchestrationError( + 'validation', + 'Cursor does not match this workspace, collection, or sort' + ) + } +} + +function pageResult( + rows: T[], + limit: number, + scope: string +) { + const items = rows.slice(0, limit) + const last = items.at(-1) + return { + items, + nextCursor: + rows.length > limit && last + ? Buffer.from( + JSON.stringify({ id: last.id, createdAt: last.cursorCreatedAt, scope }) + ).toString('base64url') + : null, + } +} + +export const getWorkspaceForkAvailability = defineForkUseCase({ + operation: forkOperations.discover, + availability: true, + execute: async ({ + context, + principal, + input: _input, + }: { + context: { workspace: { organizationId: string | null } } + principal: { userId: string } + input: WorkspaceInput + }) => ({ + available: await isForkingAvailableForWorkspace( + context.workspace.organizationId, + principal.userId + ), + }), +}) + +export const getWorkspaceForkLineage = defineForkUseCase({ + operation: forkOperations.discover, + execute: async ({ + context, + input: _input, + }: { + context: { + workspace: { id: string; name: string; organizationId: string | null } + workspaceId: string + } + input: WorkspaceInput + }) => ({ + current: { + id: context.workspace.id, + name: context.workspace.name, + organizationId: context.workspace.organizationId, + }, + parent: await getForkParent(context.workspaceId), + }), +}) + +export const listWorkspaceForkChildren = defineForkUseCase({ + operation: forkOperations.discover, + async execute({ input }: { input: PageInput }) { + const cursor = readCursor(input, { collection: 'children' }) + if (cursor.id && !cursor.createdAt) + throw new OrchestrationError('validation', 'Invalid children cursor') + const rows = await db + .select({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId, + createdAt: workspace.createdAt, + cursorCreatedAt: sql`${workspace.createdAt}::text`, + }) + .from(workspace) + .where( + and( + eq(workspace.forkedFromWorkspaceId, input.workspaceId), + isNull(workspace.archivedAt), + cursor.id + ? sql`(${workspace.createdAt}, ${workspace.id}) < (${cursor.createdAt}::timestamp, ${cursor.id})` + : undefined + ) + ) + .orderBy(desc(workspace.createdAt), desc(workspace.id)) + .limit(input.limit + 1) + const result = pageResult(rows, input.limit, cursor.scope) + return { + ...result, + items: result.items.map(({ cursorCreatedAt: _cursorCreatedAt, ...item }) => ({ + ...item, + createdAt: item.createdAt.toISOString(), + })), + } + }, +}) + +export const listWorkspaceForkResources = defineForkUseCase({ + operation: forkOperations.discover, + async execute({ + input, + }: { + input: PageInput & { kind: keyof Omit } + }) { + const cursor = readCursor(input, { collection: 'copyable_resources', kind: input.kind }) + const rows = await listForkCopyableResourcePage(db, input.workspaceId, input.kind, { + after: cursor.id, + limit: input.limit, + }) + return pageResult(rows, input.limit, cursor.scope) + }, +}) + +export const getWorkspaceForkMappings = defineForkUseCase({ + operation: forkOperations.mappingsRead, + bothSides: true, + edge: true, + async execute({ + input, + context, + }: { + input: PageInput & { otherWorkspaceId: string; direction: 'push' | 'pull' } + context: { edge?: { childWorkspaceId: string; parentWorkspaceId: string } } + }) { + if (!context.edge) throw new Error('Mapping reads require an authorized edge') + const cursor = readCursor(input, { + collection: 'mappings', + otherWorkspaceId: input.otherWorkspaceId, + direction: input.direction, + }) + const rows = await db + .select({ + id: workspaceForkResourceMap.id, + resourceType: workspaceForkResourceMap.resourceType, + parentResourceId: workspaceForkResourceMap.parentResourceId, + childResourceId: workspaceForkResourceMap.childResourceId, + }) + .from(workspaceForkResourceMap) + .where( + and( + eq(workspaceForkResourceMap.childWorkspaceId, context.edge.childWorkspaceId), + sql`${workspaceForkResourceMap.resourceType} NOT IN ('workflow', 'workflow_mcp_server', 'knowledge_document')`, + cursor.id ? gt(workspaceForkResourceMap.id, cursor.id) : undefined + ) + ) + .orderBy(asc(workspaceForkResourceMap.id)) + .limit(input.limit + 1) + const sourceId = input.direction === 'push' ? input.workspaceId : input.otherWorkspaceId + const sourceIsParent = sourceId === context.edge.parentWorkspaceId + const result = pageResult(rows, input.limit, cursor.scope) + return { + ...result, + items: result.items.flatMap((row) => { + if (!resourceTypeToForkKind(row.resourceType) || row.resourceType === 'knowledge_document') + return [] + if (row.resourceType === 'workflow' || row.resourceType === 'workflow_mcp_server') return [] + const sourceId = sourceIsParent ? row.parentResourceId : row.childResourceId + return sourceId + ? [ + { + id: row.id, + resourceType: row.resourceType, + sourceId, + targetId: sourceIsParent ? row.childResourceId : row.parentResourceId, + }, + ] + : [] + }), + } + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts new file mode 100644 index 00000000000..62026ac0102 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts @@ -0,0 +1,76 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' +import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' +import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' + +/** + * Annotates a lineage node with whether the viewer holds any access to it (explicit + * grant or org-admin derivation, via the canonical workspace-permission resolver). + * Lineage rows are visible to any admin of the CURRENT workspace, who may have no + * access to the other side of an edge; the flag drives per-action gating in the + * Forks UI. Resolved per node - lineage children lists are small and bounded. + */ +async function withViewerAccess( + node: T, + viewerId: string +): Promise { + const permission = await getEffectiveWorkspacePermission(viewerId, node) + return { ...node, viewerAccessible: permission !== null } +} + +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const getWorkspaceForkLineageDetails = defineForkUseCase({ + operation: forkOperations.discover, + availability: true, + async execute({ + input, + principal, + }: { + input: { workspaceId: string } + principal: { userId: string } + }) { + const { workspaceId } = input + const [rawParent, rawChildren, run] = await Promise.all([ + getForkParent(workspaceId), + getForkChildren(workspaceId), + getUndoableRunForTarget(db, workspaceId), + ]) + + const [parent, children] = await Promise.all([ + rawParent ? withViewerAccess(rawParent, principal.userId) : null, + Promise.all(rawChildren.map((child) => withViewerAccess(child, principal.userId))), + ]) + + let undoableRun: { + otherWorkspaceId: string + otherName: string + direction: 'push' | 'pull' + } | null = null + if (run) { + const [other] = await db + .select({ name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, run.sourceWorkspaceId)) + .limit(1) + undoableRun = { + otherWorkspaceId: run.sourceWorkspaceId, + otherName: other?.name ?? 'workspace', + direction: run.direction, + } + } + + return { + workspaceId, + parent, + children: children.map((child) => ({ + ...child, + createdAt: child.createdAt.toISOString(), + })), + undoableRun, + } + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/mapping-details.ts b/apps/sim/ee/workspace-forking/application/mapping-details.ts new file mode 100644 index 00000000000..8ae91106b38 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/mapping-details.ts @@ -0,0 +1,39 @@ +import { + defineForkUseCase, + type ForkApplicationContext, +} from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getForkMappingView } from '@/ee/workspace-forking/lib/mapping/mapping-service' + +interface MappingDetailsInput { + workspaceId: string + otherWorkspaceId: string + direction: 'push' | 'pull' +} + +export const getWorkspaceForkMappingDetails = defineForkUseCase({ + operation: forkOperations.mappingsRead, + bothSides: true, + edge: true, + async execute({ + input, + context, + }: { + input: MappingDetailsInput + context: ForkApplicationContext + }) { + const edge = context.edge! + const sourceWorkspaceId = + input.direction === 'push' ? input.workspaceId : input.otherWorkspaceId + const targetWorkspaceId = + input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId + const { entries } = await getForkMappingView({ edge, sourceWorkspaceId, targetWorkspaceId }) + return { + childWorkspaceId: edge.childWorkspaceId, + parentWorkspaceId: edge.parentWorkspaceId, + sourceWorkspaceId, + targetWorkspaceId, + entries, + } + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/operations.ts b/apps/sim/ee/workspace-forking/application/operations.ts new file mode 100644 index 00000000000..e68123e7c46 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/operations.ts @@ -0,0 +1,100 @@ +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +const adminPolicy = { + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], +} as const + +export const forkOperations = { + /** + * permission-group-exempt: fork discovery is workspace metadata governed by the source admin role. + */ + discover: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.discover', + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: fork preview uses source admin and separately validates creation policy and copied graph capabilities. + */ + preview: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.preview', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: fork creation uses source admin and separately enforces workspace creation policy and copied graph capabilities. + */ + create: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.create', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: sync preview uses admin roles on both sides and separately validates graph and resource capabilities. + */ + syncPreview: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.sync.preview', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: sync uses admin roles on both sides and separately enforces graph and resource capabilities. + */ + sync: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.sync', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: edge mappings are workspace configuration governed by admin roles on both sides. + */ + mappingsRead: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.mappings.read', + oauthScope: 'api:read', + }), + /** + * permission-group-exempt: edge mapping changes use admin roles on both sides and validate access to each destination resource. + */ + mappingsUpdate: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.mappings.update', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: restoring a prior deployment is governed by the target admin role and deployment policy. + */ + rollback: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.rollback', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: removing a fork relationship is governed by the acting workspace admin role. + */ + unlink: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.unlink', + oauthScope: 'api:write', + }), + /** + * permission-group-exempt: sync exclusions are workspace configuration governed by the acting workspace admin role. + */ + exclusions: defineWorkspaceOperation({ + ...adminPolicy, + capability: 'none', + id: 'workspaces.fork.exclusions', + oauthScope: 'api:write', + }), +} as const diff --git a/apps/sim/ee/workspace-forking/application/preview-sync.test.ts b/apps/sim/ee/workspace-forking/application/preview-sync.test.ts new file mode 100644 index 00000000000..e3d968a4b03 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/preview-sync.test.ts @@ -0,0 +1,427 @@ +/** @vitest-environment node */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: vi.fn(), +})) +vi.mock('@/lib/selectors/application/get-selector-option', () => ({ + getSelectorOption: { execute: vi.fn() }, +})) +vi.mock('@/lib/workflows/references/custom-block-reconfigs', () => ({ + collectForkCustomBlockReconfigs: vi.fn(async () => []), +})) +vi.mock('@/ee/workspace-forking/application/revision', () => ({ + loadForkPreviewRevision: vi.fn(async () => ({ fingerprint: 'reviewed' })), +})) +vi.mock('@/ee/workspace-forking/application/validate-bindings', () => ({ + validateForkWorkflowBindings: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/copy/copy-workflows', () => ({ + loadTargetDraftSubBlocks: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({ + loadSourceDeployedStates: vi.fn(), + loadTargetWebhookPathsByBlock: vi.fn(async () => new Map()), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({ + loadForkBlockMap: vi.fn(async () => ({ parentToChild: new Map(), childToParent: new Map() })), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({ + loadForkDependentValues: vi.fn(async () => []), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-service', () => ({ + overlayForkMappingEntries: vi.fn(() => []), + validateForkMappingTargets: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ + getEdgeMappingRows: vi.fn(async () => []), +})) +vi.mock('@/ee/workspace-forking/lib/promote/cleared-refs', () => ({ + collectForkSyncBlockers: vi.fn(async () => ({ blockers: [] })), + verifyForkDropAcknowledgments: vi.fn(async () => []), +})) +vi.mock('@/ee/workspace-forking/lib/promote/copy-unmapped', () => ({ + buildPromoteCopySelection: vi.fn(() => ({ willResolve: new Set() })), +})) +vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({ + computeForkPromotePlan: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/promote/trigger-urls', () => ({ + buildForkTriggerPlan: vi.fn(() => ({ slots: [], retiring: [] })), + resolveForkTriggerPaths: vi.fn(() => ({ changes: [] })), +})) + +import { getSelectorOption } from '@/lib/selectors/application/get-selector-option' +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' +import { getBlock } from '@/blocks/registry' +import { + type PreviewSyncParams, + previewForkSync, +} from '@/ee/workspace-forking/application/preview-sync' +import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows' +import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { loadForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' +import { buildPromoteCopySelection } from '@/ee/workspace-forking/lib/promote/copy-unmapped' +import { + computeForkPromotePlan, + type ForkPromotePlan, +} from '@/ee/workspace-forking/lib/promote/promote-plan' +import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls' +import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity' + +const principal: SessionPrincipal = { kind: 'session', userId: 'actor', sessionId: 'session' } +const params: PreviewSyncParams = { + edge: { parentWorkspaceId: 'source', childWorkspaceId: 'destination' }, + sourceWorkspaceId: 'source', + targetWorkspaceId: 'destination', + direction: 'pull', +} +const columns: SubBlockConfig = { + id: 'columns', + title: 'Columns', + type: 'dropdown', + selectorKey: 'table.outputColumns', + multiSelect: true, + dependsOn: ['tableId'], +} +const configs: Record = { + agent: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], + table: [{ id: 'tableId', title: 'Table', type: 'table-selector' }, columns], + mixed: [ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { id: 'tableId', title: 'Table', type: 'table-selector' }, + { ...columns, dependsOn: ['credential', 'tableId'] }, + ], + knowledge: [ + { id: 'knowledgeBaseId', title: 'Knowledge base', type: 'knowledge-base-selector' }, + { + id: 'documentId', + title: 'Document', + type: 'document-selector', + selectorKey: 'knowledge.documents', + dependsOn: ['knowledgeBaseId'], + }, + ], + sheets: [ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'spreadsheetId', + title: 'Spreadsheet', + type: 'file-selector', + selectorKey: 'google.drive', + dependsOn: ['credential'], + }, + { + id: 'sheetId', + title: 'Sheet', + type: 'sheet-selector', + selectorKey: 'google.sheets', + dependsOn: ['spreadsheetId'], + }, + ], +} + +function makeState(type: string, values: Record): WorkflowState { + return { + blocks: { + block: { + id: 'block', + type, + name: type, + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [ + id, + { + id, + type: configs[type].find((config) => config.id === id)!.type, + value: value as WorkflowState['blocks'][string]['subBlocks'][string]['value'], + }, + ]) + ), + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} + +function prepare(state: WorkflowState, options: { copied?: boolean; create?: boolean } = {}) { + const plan: ForkPromotePlan = { + childWorkspaceId: 'destination', + sourceWorkspaceId: 'source', + targetWorkspaceId: 'destination', + direction: 'pull', + resolver: (_kind, id) => (options.copied ? null : `destination-${id}`), + items: [ + { + sourceWorkflowId: 'workflow', + targetWorkflowId: 'target-workflow', + targetName: null, + mode: options.create ? 'create' : 'replace', + sourceMeta: { + name: 'Workflow', + description: null, + folderId: null, + sortOrder: 0, + isPublicApi: false, + }, + }, + ], + workflowIdMap: new Map([['workflow', 'target-workflow']]), + archivedTargetIds: [], + archivedTargets: [], + excludedTargets: [], + references: [], + unmappedRequired: [], + unmappedOptional: [], + mcpReauthServerIds: [], + inlineSecretSources: [], + copyableUnmapped: [], + willUpdate: options.create ? 0 : 1, + willCreate: options.create ? 1 : 0, + willArchive: 0, + } + vi.mocked(computeForkPromotePlan).mockResolvedValue(plan) + vi.mocked(loadSourceDeployedStates).mockResolvedValue({ + deployedWorkflows: [], + sourceStates: new Map([['workflow', state]]), + sourceVersionIds: new Map(), + }) + return plan +} + +function choices(subBlockKey: string, value: string) { + return { sourceWorkflowId: 'workflow', sourceBlockId: 'block', subBlockKey, value } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getBlock).mockImplementation((type) => + configs[type] ? ({ subBlocks: configs[type] } as BlockConfig) : undefined + ) + vi.mocked(getToolInputParamConfigs).mockImplementation(({ tool }) => + (configs[tool.type] ?? []).map((config) => ({ + paramId: config.id, + config, + authoritative: true, + value: tool.params?.[config.id], + })) + ) + vi.mocked(loadForkDependentValues).mockResolvedValue([]) + vi.mocked(buildPromoteCopySelection).mockReturnValue({ + willResolve: new Set(), + selection: { + customTools: [], + skills: [], + tables: [], + knowledgeBases: [], + files: [], + mcpServers: [], + }, + }) + vi.mocked(getSelectorOption.execute).mockImplementation(async ({ input }) => ({ + id: input.id, + label: input.id, + })) +}) + +describe('sync preview selector contexts', () => { + it('exposes source trigger identities and adoptable paths without generated target block IDs', async () => { + prepare(makeState('agent', {}), { create: true }) + const slot = { + sourceWorkflowId: 'workflow', + sourceBlockId: 'source-trigger', + blockName: 'Slack trigger', + workflowName: 'Workflow', + ownPath: null, + adoptablePaths: ['retiring-path'], + defaultAdoptPath: 'retiring-path', + } + vi.mocked(buildForkTriggerPlan) + .mockReturnValueOnce({ + slots: [{ ...slot, targetBlockId: 'first-generated-target' }], + retiring: [], + }) + .mockReturnValueOnce({ + slots: [{ ...slot, targetBlockId: 'second-generated-target' }], + retiring: [], + }) + const first = await previewForkSync(params, params, principal) + const second = await previewForkSync(params, params, principal) + expect(first.triggerSlots).toEqual([slot]) + expect(second.triggerSlots).toEqual(first.triggerSlots) + expect(first.triggerSlots[0]).not.toHaveProperty('targetBlockId') + }) + + it('validates copied table columns in the source and keeps public identities stable for new targets', async () => { + const plan = prepare(makeState('table', { tableId: 'table-source', columns: ['one', 'two'] }), { + copied: true, + create: true, + }) + vi.mocked(buildPromoteCopySelection).mockReturnValue({ + ...buildPromoteCopySelection(undefined, []), + willResolve: new Set(['table:table-source']), + }) + const input = { ...params, sourceDependentValues: [choices('columns', 'one,two')] } + const first = await previewForkSync(input, input, principal) + expect(first.configuration[0]).toMatchObject({ + sourceWorkflowId: 'workflow', + sourceBlockId: 'block', + subBlockKey: 'columns', + discoveryWorkspaceId: 'source', + context: { tableId: 'table-source' }, + currentValue: 'one,two', + }) + expect(getSelectorOption.execute).toHaveBeenCalledTimes(2) + expect(getSelectorOption.execute).toHaveBeenCalledWith({ + principal, + input: { + selectorKey: 'table.outputColumns', + scope: { kind: 'workspace', workspaceId: 'source' }, + context: { tableId: 'table-source' }, + id: 'two', + }, + }) + plan.items[0].targetWorkflowId = 'another-generated-target' + const second = await previewForkSync(input, input, principal) + expect(second.configuration).toEqual(first.configuration) + }) + + it('validates a source document pick under its copy-selected knowledge base', async () => { + prepare(makeState('knowledge', { knowledgeBaseId: 'kb-source', documentId: 'doc-source' }), { + copied: true, + }) + vi.mocked(buildPromoteCopySelection).mockReturnValue({ + ...buildPromoteCopySelection(undefined, []), + willResolve: new Set(['knowledge-base:kb-source']), + }) + const input = { ...params, sourceDependentValues: [choices('documentId', 'doc-source')] } + const preview = await previewForkSync(input, input, principal) + expect(preview.configuration[0]).toMatchObject({ + discoveryWorkspaceId: 'source', + context: { knowledgeBaseId: 'kb-source' }, + }) + expect(getSelectorOption.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + scope: { kind: 'workspace', workspaceId: 'source' }, + id: 'doc-source', + }), + }) + ) + }) + + it.each([false, true])( + 'resolves the relevant resource when a field hangs off multiple anchors (copied: %s)', + async (copied) => { + prepare( + makeState('mixed', { + credential: 'credential-source', + tableId: 'table-source', + columns: 'one', + }), + { copied } + ) + if (copied) + vi.mocked(buildPromoteCopySelection).mockReturnValue({ + ...buildPromoteCopySelection(undefined, []), + willResolve: new Set(['table:table-source']), + }) + const input = { ...params, sourceDependentValues: [choices('columns', 'one')] } + const preview = await previewForkSync(input, input, principal) + expect(preview.configuration[0]).toMatchObject({ + parentKind: 'credential', + discoveryWorkspaceId: copied ? 'source' : 'destination', + context: { tableId: copied ? 'table-source' : 'destination-table-source' }, + }) + expect(preview.configuration[0].context).not.toHaveProperty('oauthCredential') + expect(getSelectorOption.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + scope: { kind: 'workspace', workspaceId: copied ? 'source' : 'destination' }, + context: { tableId: copied ? 'table-source' : 'destination-table-source' }, + }), + }) + ) + } + ) + + it.each([false, true])( + 'tracks every nested occurrence in tool arrays (serialized: %s)', + async (serialized) => { + const tools = [0, 1].map(() => ({ + type: 'mixed', + title: 'Mixed', + params: { credential: 'credential-source', tableId: 'table-source', columns: 'one' }, + })) + prepare(makeState('agent', { tools: serialized ? JSON.stringify(tools) : tools })) + const input = { + ...params, + sourceDependentValues: [ + choices('tools[0].columns', 'one'), + choices('tools[1].columns', 'two'), + ], + } + const preview = await previewForkSync(input, input, principal) + expect(preview.configuration).toHaveLength(2) + for (const field of preview.configuration) + expect(field.context).toEqual({ tableId: 'destination-table-source' }) + } + ) + + it('uses stored siblings for selector chains and never implies that target drafts will be restored', async () => { + prepare( + makeState('sheets', { + credential: 'source-google', + spreadsheetId: 'source-book', + sheetId: 'source-sheet', + }) + ) + const targetBlockId = deriveForkBlockId('target-workflow', 'block') + const stored = (subBlockKey: string, value: string) => ({ + targetWorkflowId: 'target-workflow', + targetBlockId, + subBlockKey, + value, + }) + vi.mocked(loadForkDependentValues).mockResolvedValue([ + stored('spreadsheetId', 'stored-book'), + stored('sheetId', 'stored-sheet'), + ]) + const first = await previewForkSync(params, params, principal) + expect(first.configuration.find((field) => field.subBlockKey === 'sheetId')).toMatchObject({ + currentValue: 'stored-sheet', + context: { oauthCredential: 'destination-source-google', spreadsheetId: 'stored-book' }, + }) + expect(loadTargetDraftSubBlocks).not.toHaveBeenCalled() + vi.mocked(loadForkDependentValues).mockResolvedValue([]) + const empty = await previewForkSync(params, params, principal) + expect(empty.configuration.every((field) => field.currentValue === '')).toBe(true) + expect( + empty.configuration.find((field) => field.subBlockKey === 'sheetId')?.context.spreadsheetId + ).toBe('') + }) + + it('treats an explicit empty override list as clearing saved selections', async () => { + prepare(makeState('table', { tableId: 'table-source', columns: 'source-column' })) + vi.mocked(loadForkDependentValues).mockResolvedValue([ + { + targetWorkflowId: 'target-workflow', + targetBlockId: deriveForkBlockId('target-workflow', 'block'), + subBlockKey: 'columns', + value: 'saved-column', + }, + ]) + const input = { ...params, sourceDependentValues: [] } + const preview = await previewForkSync(input, input, principal) + expect(preview.configuration[0].currentValue).toBe('') + expect(getSelectorOption.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/preview-sync.ts b/apps/sim/ee/workspace-forking/application/preview-sync.ts new file mode 100644 index 00000000000..559d500d80d --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/preview-sync.ts @@ -0,0 +1,329 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' +import { collectForkCustomBlockReconfigs } from '@/lib/workflows/references/custom-block-reconfigs' +import { collectForkDependentReconfigs } from '@/lib/workflows/references/dependent-reconfigs' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import { assertWorkflowPreviewFits } from '@/lib/workflows/references/preview-limits' +import { workflowSelectorValidator } from '@/lib/workflows/references/selector-values' +import { loadForkPreviewRevision } from '@/ee/workspace-forking/application/revision' +import { validateForkWorkflowBindings } from '@/ee/workspace-forking/application/validate-bindings' +import { + loadSourceDeployedStates, + loadTargetWebhookPathsByBlock, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { loadForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' +import { + overlayForkMappingEntries, + validateForkMappingTargets, +} from '@/ee/workspace-forking/lib/mapping/mapping-service' +import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store' +import { + collectForkSyncBlockers, + verifyForkDropAcknowledgments, +} from '@/ee/workspace-forking/lib/promote/cleared-refs' +import { buildPromoteCopySelection } from '@/ee/workspace-forking/lib/promote/copy-unmapped' +import type { PromoteForkParams } from '@/ee/workspace-forking/lib/promote/promote' +import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { + buildForkTriggerPlan, + resolveForkTriggerPaths, +} from '@/ee/workspace-forking/lib/promote/trigger-urls' +import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' + +const RESOURCE_CONTEXT_KINDS = { + oauthCredential: 'credential', + knowledgeBaseId: 'knowledge-base', + tableId: 'table', + mcpServerId: 'mcp-server', +} as const + +export type SyncChoices = Pick< + PromoteForkParams, + 'copyResources' | 'dropReferences' | 'triggerMappings' | 'mappings' | 'sourceDependentValues' +> +export type PreviewSyncParams = Pick< + PromoteForkParams, + 'edge' | 'sourceWorkspaceId' | 'targetWorkspaceId' | 'direction' +> & + SyncChoices + +/** Builds a read-only plan using the same mapping, copy, reference, and trigger rules as apply. */ +export async function previewForkSync( + params: PreviewSyncParams, + choices: Record, + principal: Principal +) { + const { edge, sourceWorkspaceId, targetWorkspaceId } = params + const revision = await loadForkPreviewRevision(db, params, choices) + await validateForkMappingTargets(sourceWorkspaceId, targetWorkspaceId, params.mappings ?? []) + const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates(sourceWorkspaceId) + const mappingRows = overlayForkMappingEntries( + await getEdgeMappingRows(db, edge.childWorkspaceId), + edge, + sourceWorkspaceId, + params.mappings ?? [] + ) + const plan = await computeForkPromotePlan({ + ...params, + executor: db, + deployedSourceWorkflows: deployedWorkflows, + sourceStates, + mappingRows, + }) + await validateForkWorkflowBindings({ + executor: db, + workspaceId: targetWorkspaceId, + sourceStates, + items: plan.items, + resolve: plan.resolver, + principal, + }) + const resolveBlockId = buildForkBlockIdResolver( + sourceWorkspaceId === edge.parentWorkspaceId, + await loadForkBlockMap(db, edge.childWorkspaceId) + ) + const { willResolve } = buildPromoteCopySelection(params.copyResources, plan.copyableUnmapped) + const verifiedDrops = await verifyForkDropAcknowledgments( + db, + sourceWorkspaceId, + params.dropReferences + ) + const { blockers } = await collectForkSyncBlockers({ + executor: db, + sourceWorkspaceId, + items: plan.items, + sourceStates, + resolver: (kind, id) => (willResolve.has(`${kind}:${id}`) ? id : plan.resolver(kind, id)), + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional], + droppedReferences: verifiedDrops, + }) + const sourceItems = plan.items.map((item) => ({ + ...item, + targetWorkflowId: item.sourceWorkflowId, + })) + const fields = [ + ...collectForkDependentReconfigs(sourceItems, sourceStates, (_workflowId, blockId) => blockId), + ...collectForkDependentReconfigs( + sourceItems, + sourceStates, + (_workflowId, blockId) => blockId, + 'create' + ), + ...(await collectForkCustomBlockReconfigs({ + items: sourceItems, + sourceStates, + resolveTargetBlockId: (_workflowId, blockId) => blockId, + resolve: plan.resolver, + targetWorkspaceId, + })), + ] + const stored = await loadForkDependentValues( + db, + edge.childWorkspaceId, + plan.items.map((item) => item.targetWorkflowId) + ) + assertWorkflowPreviewFits({ configuration: fields }) + const sourceAnchors = new Map>() + for (const item of plan.items) { + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + for (const reference of buildWorkflowReferenceManifest(state.blocks).references) { + if (!Object.values(RESOURCE_CONTEXT_KINDS).some((kind) => kind === reference.kind)) continue + for (const occurrence of reference.occurrences) { + const scope = + typeof occurrence.valuePath[0] === 'number' + ? `${occurrence.subBlockKey}[${occurrence.valuePath[0]}]` + : undefined + const key = JSON.stringify([item.sourceWorkflowId, occurrence.blockId, scope]) + const anchors = sourceAnchors.get(key) ?? new Set() + anchors.add(`${reference.kind}:${reference.sourceId}`) + sourceAnchors.set(key, anchors) + } + } + } + const provided = params.sourceDependentValues ?? [] + const seen = new Set() + for (const value of provided) { + const key = JSON.stringify([value.sourceWorkflowId, value.sourceBlockId, value.subBlockKey]) + if (seen.has(key)) + throw new OrchestrationError('validation', 'Duplicate dependent field instruction') + seen.add(key) + if ( + !fields.some( + (field) => + field.targetWorkflowId === value.sourceWorkflowId && + field.targetBlockId === value.sourceBlockId && + field.subBlockKey === value.subBlockKey + ) + ) + throw new OrchestrationError( + 'validation', + 'Dependent override does not address a configurable source field' + ) + } + const resolvedValues = new Map( + fields.map((field) => { + const item = plan.items.find((item) => item.sourceWorkflowId === field.targetWorkflowId)! + const targetBlockId = resolveBlockId(item.targetWorkflowId, field.targetBlockId) + const supplied = provided.find( + (value) => + value.sourceWorkflowId === field.targetWorkflowId && + value.sourceBlockId === field.targetBlockId && + value.subBlockKey === field.subBlockKey + ) + const saved = + params.sourceDependentValues === undefined + ? stored.find( + (value) => + value.targetWorkflowId === item.targetWorkflowId && + value.targetBlockId === targetBlockId && + value.subBlockKey === field.subBlockKey + )?.value + : undefined + return [ + JSON.stringify([field.targetWorkflowId, field.targetBlockId, field.subBlockKey]), + supplied?.value ?? saved ?? '', + ] as const + }) + ) + const configuration = fields.map((field) => { + const context = { ...field.context } + if (field.parentContextKey) context[field.parentContextKey] = field.parentSourceId + const manifest = field.selectorKey + ? getSelectorManifestEntry(field.selectorKey as SelectorKey) + : undefined + if (manifest) + for (const key of Object.keys(context)) + if (!manifest.context.allowed.some((allowed) => allowed === key)) delete context[key] + const anchors = sourceAnchors.get( + JSON.stringify([field.targetWorkflowId, field.targetBlockId, field.dependencyScope]) + ) + const resources = Object.entries(RESOURCE_CONTEXT_KINDS).flatMap(([key, kind]) => { + const id = context[key] + return id && + (anchors?.has(`${kind}:${id}`) || + (key === field.parentContextKey && kind === field.parentKind)) + ? [{ key, kind, id, copied: willResolve.has(`${kind}:${id}`) }] + : [] + }) + const discoverSource = resources.some((resource) => resource.copied) + if (discoverSource && resources.some((resource) => !resource.copied)) + throw new OrchestrationError( + 'validation', + `Map the parent resources of ${field.title} before configuring dependencies from different workspaces` + ) + for (const resource of resources) + context[resource.key] = discoverSource + ? resource.id + : (plan.resolver(resource.kind, resource.id) ?? '') + for (const sibling of fields) { + if ( + sibling.targetWorkflowId !== field.targetWorkflowId || + sibling.targetBlockId !== field.targetBlockId || + sibling.dependencyScope !== field.dependencyScope || + !sibling.providesContextKey || + (manifest && + !manifest.context.allowed.some((allowed) => allowed === sibling.providesContextKey)) + ) + continue + context[sibling.providesContextKey] = + resolvedValues.get( + JSON.stringify([sibling.targetWorkflowId, sibling.targetBlockId, sibling.subBlockKey]) + ) ?? '' + } + return { + sourceWorkflowId: field.targetWorkflowId, + sourceBlockId: field.targetBlockId, + subBlockKey: field.subBlockKey, + title: field.title, + required: field.required, + currentValue: + resolvedValues.get( + JSON.stringify([field.targetWorkflowId, field.targetBlockId, field.subBlockKey]) + ) ?? '', + selectorKey: field.selectorKey, + multiSelect: field.multiSelect, + discoveryWorkspaceId: discoverSource ? sourceWorkspaceId : targetWorkspaceId, + context, + parentKind: field.parentKind, + parentSourceId: field.parentSourceId, + parentContextKey: field.parentContextKey, + } + }) + const validators = new Map([ + [sourceWorkspaceId, workflowSelectorValidator(principal, sourceWorkspaceId)], + [targetWorkspaceId, workflowSelectorValidator(principal, targetWorkspaceId)], + ]) + for (const field of configuration) { + if (!field.selectorKey || !field.currentValue) continue + if ( + !(await validators.get(field.discoveryWorkspaceId)!({ + ...field, + selectorKey: field.selectorKey, + value: field.currentValue, + })) + ) + throw new OrchestrationError( + 'validation', + `${field.title} is not available under its destination dependencies` + ) + } + for (const field of configuration) + if (field.selectorKey) { + for (const sensitive of getSelectorManifestEntry(field.selectorKey as SelectorKey).context + .sensitive ?? []) + delete field.context[sensitive] + } + const triggerPlan = buildForkTriggerPlan({ + items: plan.items, + sourceStates, + resolveBlockId, + targetWebhooks: await loadTargetWebhookPathsByBlock( + db, + plan.items.map((item) => item.targetWorkflowId) + ), + }) + const triggerResolution = resolveForkTriggerPaths(triggerPlan, params.triggerMappings) + const after = await loadForkPreviewRevision(db, params, choices) + if (after.fingerprint !== revision.fingerprint) + throw new OrchestrationError( + 'conflict', + 'Workspace changed during preview; request another preview' + ) + const preview = { + previewFingerprint: revision.fingerprint, + sourceWorkspaceId, + targetWorkspaceId, + ready: blockers.length === 0, + workflows: [ + ...plan.items.map((item) => ({ + action: item.mode, + sourceWorkflowId: item.sourceWorkflowId, + ...(item.mode === 'replace' ? { targetWorkflowId: item.targetWorkflowId } : {}), + name: item.sourceMeta.name, + })), + ...plan.archivedTargets.map((item) => ({ + action: 'archive' as const, + targetWorkflowId: item.id, + name: item.name, + })), + ], + unresolvedBindings: blockers.map((blocker) => ({ + kind: blocker.kind, + sourceId: blocker.sourceId, + blockName: blocker.blockLabel, + reason: blocker.reason, + })), + configuration, + excludedTargets: plan.excludedTargets, + triggerSlots: triggerPlan.slots.map(({ targetBlockId: _targetBlockId, ...slot }) => slot), + triggerUrlChanges: triggerResolution.changes, + } + assertWorkflowPreviewFits(preview) + return preview +} diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.test.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.test.ts new file mode 100644 index 00000000000..70d52481887 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + rollback: vi.fn(), + activity: vi.fn(), + analytics: vi.fn(), + audit: vi.fn(), + authorize: vi.fn(), + workspace: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork.rolled_back', + WORKSPACE_FORK_UNLINKED: 'workspace.fork.unlinked', + WORKFLOW_FORK_SYNC_EXCLUDED: 'workflow.fork_sync.excluded', + WORKFLOW_FORK_SYNC_INCLUDED: 'workflow.fork_sync.included', + }, + AuditResourceType: { WORKSPACE: 'workspace' }, + recordAudit: mocks.audit, +})) +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mocks.authorize, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mocks.workspace, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.analytics })) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ assertForkingEnabled: vi.fn() })) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ + acquireForkEdgeLock: vi.fn(), + setForkLockTimeout: vi.fn(), + resolveForkEdge: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/lineage/unlink', () => ({ unlinkForkEdge: vi.fn() })) +vi.mock('@/ee/workspace-forking/lib/mapping/dependent-value-store', () => ({ + reconcileForkDependentValues: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-service', () => ({ + applyForkMappingEntries: vi.fn(), + overlayForkMappingEntries: vi.fn(), + validateForkMappingTargets: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ + getEdgeMappingRows: vi.fn(), +})) +vi.mock('@/ee/workspace-forking/lib/promote/rollback', () => ({ rollbackFork: mocks.rollback })) +vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({ + recordBackgroundWork: mocks.activity, +})) + +import { + rollbackWorkspaceFork, + updateWorkspaceForkExclusions, +} from '@/ee/workspace-forking/application/recovery-and-mappings' + +const principal: SessionPrincipal = { + kind: 'session', + userId: 'actor-1', + sessionId: 'session-1', +} +const rollbackResult = { + restored: 2, + archived: 1, + unarchived: 0, + skipped: 0, + skippedIds: [], + pendingActivations: [], +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.workspace.mockResolvedValue({ + id: 'target', + name: 'Destination', + organizationId: null, + allowPersonalApiKeys: true, + }) + mocks.authorize.mockResolvedValue(undefined) + mocks.rollback.mockResolvedValue(rollbackResult) + mocks.activity.mockResolvedValue(undefined) +}) + +describe('shared fork rollback effects', () => { + it.each([ + { pendingActivations: [], skipped: 0, status: 'completed' }, + { pendingActivations: [], skipped: 1, status: 'completed_with_warnings' }, + { pendingActivations: ['workflow-1'], skipped: 0, status: 'completed_with_warnings' }, + ])( + 'records activity with $status for $skipped skipped workflows and $pendingActivations pending deployments', + async ({ pendingActivations, skipped, status }) => { + const result = { ...rollbackResult, pendingActivations, skipped } + mocks.rollback.mockResolvedValue(result) + queueTableRows(workspace, [{ name: 'Source', actorName: 'Acting user' }]) + + await expect( + rollbackWorkspaceFork.execute({ + principal, + input: { workspaceId: 'target', otherWorkspaceId: 'source' }, + }) + ).resolves.toEqual(result) + + expect(mocks.activity).toHaveBeenCalledWith(db, { + workspaceId: 'target', + kind: 'fork_rollback', + status, + message: pendingActivations.length + ? 'Undid the last sync from "Source" — 1 deployment(s) still activating' + : 'Undid the last sync from "Source"', + metadata: { + actorName: 'Acting user', + otherWorkspaceId: 'source', + otherWorkspaceName: 'Source', + restored: 2, + removed: 1, + unarchived: 0, + skipped, + pendingActivations: pendingActivations.length, + }, + }) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'actor-1', + resourceId: 'target', + resourceName: 'Destination', + description: 'Rolled back the last promote into "Destination"', + }) + ) + } + ) + + it('keeps a committed rollback successful if activity recording fails', async () => { + mocks.activity.mockRejectedValue(new Error('Activity storage unavailable')) + await expect( + rollbackWorkspaceFork.execute({ + principal, + input: { workspaceId: 'target', otherWorkspaceId: 'source' }, + }) + ).resolves.toEqual(rollbackResult) + expect(mocks.activity).toHaveBeenCalledWith( + db, + expect.objectContaining({ + message: 'Undid the last sync from "the source workspace"', + }) + ) + }) + + it('does not record activity or audit when rollback refuses before commit', async () => { + mocks.rollback.mockRejectedValue(new Error('No rollback point')) + await expect( + rollbackWorkspaceFork.execute({ + principal, + input: { workspaceId: 'target', otherWorkspaceId: 'source' }, + }) + ).rejects.toThrow('No rollback point') + expect(mocks.activity).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) + +describe('shared fork exclusion effects', () => { + it.each([true, false])( + 'captures changed workflow counts and bounded audit names for excluded=%s', + async (forkSyncExcluded) => { + const rows = Array.from({ length: 25 }, (_, index) => ({ + id: `workflow-${index}`, + name: `Workflow ${index}`, + })) + dbChainMockFns.returning.mockResolvedValueOnce(rows) + + const result = await updateWorkspaceForkExclusions.execute({ + principal, + input: { workspaceId: 'target', workflowIds: rows.map((row) => row.id), forkSyncExcluded }, + }) + + expect(result.updated).toBe(25) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: forkSyncExcluded ? 'workflow.fork_sync.excluded' : 'workflow.fork_sync.included', + resourceName: 'Destination', + metadata: expect.objectContaining({ + forkSyncExcluded, + workflowCount: 25, + workflowNames: rows.slice(0, 20).map((row) => row.name), + }), + }) + ) + expect(mocks.analytics).toHaveBeenCalledWith( + 'actor-1', + 'fork_excluded_workflows_updated', + { + workspace_id: 'target', + workflow_count: 25, + fork_sync_excluded: forkSyncExcluded, + }, + { groups: { workspace: 'target' } } + ) + } + ) + + it('emits no audit or analytics for an unchanged exclusion set', async () => { + const result = await updateWorkspaceForkExclusions.execute({ + principal, + input: { workspaceId: 'target', workflowIds: ['unchanged'], forkSyncExcluded: true }, + }) + expect(result.updated).toBe(0) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.analytics).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts new file mode 100644 index 00000000000..3de7601ee77 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts @@ -0,0 +1,246 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { user, workflow, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import type { UpdateForkMappingBody } from '@/lib/api/contracts/workspace-fork' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { captureServerEvent } from '@/lib/posthog/server' +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' +import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage' +import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' +import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' +import { + type ApplyForkMappingEntry, + applyForkMappingEntries, + overlayForkMappingEntries, + validateForkMappingTargets, +} from '@/ee/workspace-forking/lib/mapping/mapping-service' +import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store' +import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback' + +const logger = createLogger('WorkspaceForkRecovery') +const AUDIT_NAME_LIMIT = 20 + +interface EdgeInput { + workspaceId: string + otherWorkspaceId: string +} +interface MappingInput extends EdgeInput { + direction: 'push' | 'pull' + mappings: ApplyForkMappingEntry[] + dependentValues?: UpdateForkMappingBody['dependentValues'] +} + +export const updateWorkspaceForkMappings = defineForkUseCase< + typeof forkOperations.mappingsUpdate, + MappingInput, + { updated: number } +>({ + operation: forkOperations.mappingsUpdate, + bothSides: true, + edge: true, + async execute({ principal, input, context }) { + const edge = context.edge! + const sourceWorkspaceId = + input.direction === 'push' ? input.workspaceId : input.otherWorkspaceId + const targetWorkspaceId = + input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId + return db.transaction(async (tx) => { + await setForkLockTimeout(tx) + await acquireForkEdgeLock(tx, edge.childWorkspaceId) + const [currentEdge] = await tx + .select({ parentId: workspace.forkedFromWorkspaceId }) + .from(workspace) + .where(and(eq(workspace.id, edge.childWorkspaceId), isNull(workspace.archivedAt))) + .for('update') + .limit(1) + if (currentEdge?.parentId !== edge.parentWorkspaceId) + throw new OrchestrationError('conflict', 'Fork lineage changed') + await validateForkMappingTargets(sourceWorkspaceId, targetWorkspaceId, input.mappings, tx) + overlayForkMappingEntries( + await getEdgeMappingRows(tx, edge.childWorkspaceId), + edge, + sourceWorkspaceId, + input.mappings + ) + const updated = await applyForkMappingEntries( + tx, + edge, + principal.userId, + sourceWorkspaceId, + input.mappings + ) + if (input.dependentValues !== undefined) { + const targetWorkflowIds = [ + ...new Set(input.dependentValues.map((value) => value.workflowId)), + ] + const targetRows = targetWorkflowIds.length + ? await tx + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + inArray(workflow.id, targetWorkflowIds), + eq(workflow.workspaceId, targetWorkspaceId), + isNull(workflow.archivedAt) + ) + ) + : [] + if (targetRows.length !== targetWorkflowIds.length) + throw new OrchestrationError( + 'validation', + 'Dependent values must belong to destination workflows' + ) + await reconcileForkDependentValues( + tx, + edge.childWorkspaceId, + targetWorkflowIds, + input.dependentValues.map((entry) => ({ + targetWorkflowId: entry.workflowId, + targetBlockId: entry.blockId, + subBlockKey: entry.subBlockKey, + value: entry.value, + })) + ) + } + return { updated } + }) + }, +}) + +export const rollbackWorkspaceFork = defineForkUseCase< + typeof forkOperations.rollback, + EdgeInput, + Awaited> +>({ + operation: forkOperations.rollback, + execute: ({ principal, input }) => + rollbackFork({ + targetWorkspaceId: input.workspaceId, + otherWorkspaceId: input.otherWorkspaceId, + userId: principal.userId, + requestId: generateShortId(), + }), + projectAudit: ({ input, context, result }) => ({ + action: AuditAction.WORKSPACE_FORK_ROLLED_BACK, + resourceType: AuditResourceType.WORKSPACE, + resourceId: input.workspaceId, + resourceName: context.workspace.name, + description: `Rolled back the last promote into "${context.workspace.name}"`, + metadata: { otherWorkspaceId: input.otherWorkspaceId, ...result }, + }), + async afterSuccess({ principal, input, result }) { + try { + const [other] = await db + .select({ name: workspace.name, actorName: user.name }) + .from(workspace) + .leftJoin(user, eq(user.id, principal.userId)) + .where(eq(workspace.id, input.otherWorkspaceId)) + .limit(1) + const otherName = other?.name ?? 'the source workspace' + const pendingActivations = result.pendingActivations.length + await recordBackgroundWork(db, { + workspaceId: input.workspaceId, + kind: 'fork_rollback', + status: + result.skipped > 0 || pendingActivations > 0 ? 'completed_with_warnings' : 'completed', + message: + pendingActivations > 0 + ? `Undid the last sync from "${otherName}" — ${pendingActivations} deployment(s) still activating` + : `Undid the last sync from "${otherName}"`, + metadata: { + actorName: other?.actorName ?? undefined, + otherWorkspaceId: input.otherWorkspaceId, + otherWorkspaceName: otherName, + restored: result.restored, + removed: result.archived, + unarchived: result.unarchived, + skipped: result.skipped, + pendingActivations, + }, + }) + } catch (error) { + logger.error('Failed to record rollback activity', { error: getErrorMessage(error) }) + } + }, +}) + +export const unlinkWorkspaceFork = defineForkUseCase< + typeof forkOperations.unlink, + EdgeInput, + Awaited> +>({ + operation: forkOperations.unlink, + edge: true, + execute: ({ context }) => unlinkForkEdge(context.edge!, generateShortId()), + projectAudit: ({ input, result }) => + result.unlinked + ? { + action: AuditAction.WORKSPACE_FORK_UNLINKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: input.workspaceId, + metadata: { otherWorkspaceId: input.otherWorkspaceId }, + } + : [], +}) + +export const updateWorkspaceForkExclusions = defineForkUseCase< + typeof forkOperations.exclusions, + { workspaceId: string; workflowIds: string[]; forkSyncExcluded: boolean }, + { updated: number; workflowNames: string[] } +>({ + operation: forkOperations.exclusions, + async execute({ input }) { + const rows = await db + .update(workflow) + .set({ forkSyncExcluded: input.forkSyncExcluded, updatedAt: new Date() }) + .where( + and( + inArray(workflow.id, input.workflowIds), + eq(workflow.workspaceId, input.workspaceId), + isNull(workflow.archivedAt), + ne(workflow.forkSyncExcluded, input.forkSyncExcluded) + ) + ) + .returning({ id: workflow.id, name: workflow.name }) + return { + updated: rows.length, + workflowNames: rows.slice(0, AUDIT_NAME_LIMIT).map((row) => row.name), + } + }, + projectAudit: ({ input, context, result }) => + result.updated + ? { + action: input.forkSyncExcluded + ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED + : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: input.workspaceId, + resourceName: context.workspace.name, + description: `${input.forkSyncExcluded ? 'Excluded' : 'Included'} ${result.updated} workflow(s) ${input.forkSyncExcluded ? 'from' : 'in'} fork sync`, + metadata: { + forkSyncExcluded: input.forkSyncExcluded, + workflowCount: result.updated, + workflowNames: result.workflowNames, + }, + } + : [], + afterSuccess({ principal, input, result }) { + if (!result.updated) return + captureServerEvent( + principal.userId, + 'fork_excluded_workflows_updated', + { + workspace_id: input.workspaceId, + workflow_count: result.updated, + fork_sync_excluded: input.forkSyncExcluded, + }, + { groups: { workspace: input.workspaceId } } + ) + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/resource-details.ts b/apps/sim/ee/workspace-forking/application/resource-details.ts new file mode 100644 index 00000000000..7a3d2daf831 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/resource-details.ts @@ -0,0 +1,11 @@ +import { db } from '@sim/db' +import { listForkCopyableResources } from '@/lib/workflows/references/resources' +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const getWorkspaceForkResourceDetails = defineForkUseCase({ + operation: forkOperations.discover, + availability: true, + execute: ({ input }: { input: { workspaceId: string } }) => + listForkCopyableResources(db, input.workspaceId), +}) diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts new file mode 100644 index 00000000000..2bb20288ae4 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/revision.ts @@ -0,0 +1,199 @@ +import { + credential, + customBlock, + customTools, + folder, + knowledgeBase, + mcpServers, + permissions, + skill, + userTableDefinitions, + webhook, + workflow, + workflowBlocks, + workflowDeploymentVersion, + workflowEdges, + workflowSubflows, + workspace, + workspaceEnvironment, + workspaceFiles, + workspaceForkBlockMap, + workspaceForkDependentValue, + workspaceForkResourceMap, + workspaceSandbox, +} from '@sim/db/schema' +import { type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { + WorkspaceOperationConflict, + workflowOperationFingerprint, +} from '@/lib/workspaces/operations/receipts' +import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' +import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' + +export interface ForkRevisionScope { + sourceWorkspaceId: string + targetWorkspaceId?: string + edge?: ForkEdge +} + +export interface ForkMutationAdmission { + workspaceId: string + requestId: string + requestHash: string + previewFingerprint: string + choices: Record +} + +/** Digests are bounded database aggregates; graph and secret values never enter preview diagnostics. */ +export async function loadForkPreviewRevision( + executor: DbOrTx, + scope: ForkRevisionScope, + choices: Record +) { + const ids = [ + ...new Set([ + scope.sourceWorkspaceId, + ...(scope.targetWorkspaceId ? [scope.targetWorkspaceId] : []), + ]), + ].sort() + const values = sql.join( + ids.map((id) => sql`${id}`), + sql`, ` + ) + const workflowIds = sql`SELECT id FROM ${workflow} WHERE workspace_id IN (${values})` + const queries: Record = { + workspaces: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at'] AS state FROM ${workspace} r WHERE id IN (${values})`, + workflows: sql`SELECT id, to_jsonb(r) - ARRAY['run_count', 'last_run_at', 'last_synced', 'updated_at'] AS state FROM ${workflow} r WHERE workspace_id IN (${values})`, + source_deployments: sql`SELECT d.id, to_jsonb(d) AS state FROM ${workflowDeploymentVersion} d JOIN ${workflow} w ON w.id = d.workflow_id WHERE w.workspace_id = ${scope.sourceWorkspaceId} AND d.is_active = true AND w.archived_at IS NULL AND w.fork_sync_excluded = false`, + target_graph: sql`SELECT 'block:' || b.id AS id, to_jsonb(b) - ARRAY['updated_at', 'created_at'] AS state FROM ${workflowBlocks} b JOIN ${workflow} w ON w.id = b.workflow_id WHERE w.workspace_id = ${scope.targetWorkspaceId ?? scope.sourceWorkspaceId} + UNION ALL SELECT 'edge:' || e.id, to_jsonb(e) - 'created_at' FROM ${workflowEdges} e JOIN ${workflow} w ON w.id = e.workflow_id WHERE w.workspace_id = ${scope.targetWorkspaceId ?? scope.sourceWorkspaceId} + UNION ALL SELECT 'subflow:' || s.id, to_jsonb(s) - ARRAY['updated_at', 'created_at'] FROM ${workflowSubflows} s JOIN ${workflow} w ON w.id = s.workflow_id WHERE w.workspace_id = ${scope.targetWorkspaceId ?? scope.sourceWorkspaceId}`, + triggers: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'last_triggered_at'] AS state FROM ${webhook} r WHERE workflow_id IN (${workflowIds})`, + membership: sql`SELECT id, to_jsonb(r) AS state FROM ${permissions} r WHERE entity_type = 'workspace' AND entity_id IN (${values})`, + folders: sql`SELECT id, to_jsonb(r) AS state FROM ${folder} r WHERE workspace_id IN (${values})`, + tables: sql`SELECT id, to_jsonb(r) AS state FROM ${userTableDefinitions} r WHERE workspace_id IN (${values})`, + knowledge: sql`SELECT id, to_jsonb(r) AS state FROM ${knowledgeBase} r WHERE workspace_id IN (${values})`, + tools: sql`SELECT id, to_jsonb(r) AS state FROM ${customTools} r WHERE workspace_id IN (${values})`, + skills: sql`SELECT id, to_jsonb(r) AS state FROM ${skill} r WHERE workspace_id IN (${values})`, + servers: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'last_connected_at', 'last_tools_refresh', 'tool_count', 'connection_status', 'last_error'] AS state FROM ${mcpServers} r WHERE workspace_id IN (${values})`, + files: sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceFiles} r WHERE workspace_id IN (${values})`, + credentials: sql`SELECT id, to_jsonb(r) - ARRAY['updated_at', 'last_used_at'] AS state FROM ${credential} r WHERE workspace_id IN (${values})`, + secrets: sql`SELECT id, to_jsonb(r) - 'updated_at' AS state FROM ${workspaceEnvironment} r WHERE workspace_id IN (${values})`, + sandboxes: sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceSandbox} r WHERE workspace_id IN (${values})`, + custom_blocks: sql`SELECT b.id, to_jsonb(b) || jsonb_build_object('deployment', d.state) AS state FROM ${customBlock} b JOIN ${workspace} w ON w.organization_id = b.organization_id LEFT JOIN ${workflowDeploymentVersion} d ON d.workflow_id = b.workflow_id AND d.is_active = true WHERE w.id = ${scope.targetWorkspaceId ?? scope.sourceWorkspaceId}`, + } + if (scope.edge) { + queries.mappings = sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceForkResourceMap} r WHERE child_workspace_id = ${scope.edge.childWorkspaceId}` + queries.block_identities = sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceForkBlockMap} r WHERE child_workspace_id = ${scope.edge.childWorkspaceId}` + queries.dependent_values = sql`SELECT id, to_jsonb(r) AS state FROM ${workspaceForkDependentValue} r WHERE child_workspace_id = ${scope.edge.childWorkspaceId}` + } + const categories: Record = {} + for (const [category, rows] of Object.entries(queries)) { + const [size] = await executor.execute<{ count: string; bytes: string }>( + sql`SELECT count(*)::text AS count, coalesce(sum(octet_length(state::text)), 0)::text AS bytes FROM (${rows}) revision_rows` + ) + if (Number(size.count) > 100000 || Number(size.bytes) > 64 * 1024 * 1024) + throw new ForkError(`Fork preview ${category} exceeds its row or 64 MiB byte ceiling`, 413) + const [revision] = await executor.execute<{ digest: string }>( + sql`SELECT md5(coalesce(string_agg(md5(state::text), '' ORDER BY id), '')) AS digest FROM (${rows}) revision_rows` + ) + categories[category] = revision.digest + } + return { + categories, + fingerprint: workflowOperationFingerprint({ + scope: { + sourceWorkspaceId: scope.sourceWorkspaceId, + targetWorkspaceId: scope.targetWorkspaceId, + edge: scope.edge, + }, + choices, + categories, + }), + } +} + +/** Locks normalized graph rows as well as workflow metadata, including realtime-only writes. */ +export async function lockForkRevision(tx: DbOrTx, scope: ForkRevisionScope): Promise { + const workspaceIds = [ + ...new Set([ + scope.sourceWorkspaceId, + ...(scope.targetWorkspaceId ? [scope.targetWorkspaceId] : []), + ]), + ].sort() + for (const id of workspaceIds) await acquireFolderMutationLock(tx, id, 'workflow') + const values = sql.join( + workspaceIds.map((id) => sql`${id}`), + sql`, ` + ) + await tx.execute(sql`SELECT id FROM ${workspace} WHERE id IN (${values}) ORDER BY id FOR UPDATE`) + await tx.execute( + sql`SELECT id FROM ${workflow} WHERE workspace_id IN (${values}) ORDER BY id FOR UPDATE` + ) + for (const table of [workflowBlocks, workflowEdges, workflowSubflows]) { + await tx.execute( + sql`SELECT r.id FROM ${table} r JOIN ${workflow} w ON w.id = r.workflow_id WHERE w.workspace_id IN (${values}) ORDER BY r.id FOR UPDATE OF r` + ) + } + await tx.execute( + sql`SELECT d.id FROM ${workflowDeploymentVersion} d JOIN ${workflow} w ON w.id = d.workflow_id WHERE w.workspace_id IN (${values}) AND d.is_active = true ORDER BY d.id FOR SHARE OF d` + ) + if (scope.edge) { + const [edge] = await tx.execute<{ parent: string | null }>( + sql`SELECT forked_from_workspace_id AS parent FROM ${workspace} WHERE id = ${scope.edge.childWorkspaceId} AND archived_at IS NULL` + ) + if (edge?.parent !== scope.edge.parentWorkspaceId) + throw new WorkspaceOperationConflict('Fork lineage changed', { + applied: false, + reason: 'stale_preview', + changed: ['lineage'], + }) + } +} + +export async function assertForkPreviewFresh( + executor: DbOrTx, + scope: ForkRevisionScope, + admission: ForkMutationAdmission +): Promise { + const revision = await loadForkPreviewRevision(executor, scope, admission.choices) + if (revision.fingerprint !== admission.previewFingerprint) + throw new WorkspaceOperationConflict('Fork preview is stale; request a new preview', { + applied: false, + requestId: admission.requestId, + reason: 'stale_preview', + previewFingerprint: revision.fingerprint, + }) +} + +/** Verifies the exact source snapshots materialized before acquiring the apply transaction. */ +export async function assertForkSourceVersions( + tx: DbOrTx, + sourceWorkspaceId: string, + expected: ReadonlyMap +): Promise { + const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql` + SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w + JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true + WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true + AND w.archived_at IS NULL AND w.fork_sync_excluded = false + `) + if ( + rows.length !== expected.size || + rows.some( + (row) => + expected.get(row.workflowId)?.id !== row.id || + expected.get(row.workflowId)?.digest !== row.digest + ) + ) + throw new WorkspaceOperationConflict( + 'Source deployment changed during loading; request a new preview', + { + applied: false, + reason: 'stale_preview', + changed: ['source_deployments'], + } + ) +} diff --git a/apps/sim/ee/workspace-forking/application/sync-details.ts b/apps/sim/ee/workspace-forking/application/sync-details.ts new file mode 100644 index 00000000000..fd5b8d34965 --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/sync-details.ts @@ -0,0 +1,293 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { collectForkCustomBlockReconfigs } from '@/lib/workflows/references/custom-block-reconfigs' +import { + collectForkDependentReconfigs, + collectForkResourceUsages, +} from '@/lib/workflows/references/dependent-reconfigs' +import { readTargetDraftDependentValue } from '@/lib/workflows/references/remap-references' +import { listForkResourceCandidates } from '@/lib/workflows/references/resources' +import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows' +import { + listForkExcludedDeployedWorkflows, + loadSourceDeployedStates, + loadTargetWebhookPathsByBlock, +} from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { + forkDependentValueKey, + loadForkDependentValues, +} from '@/ee/workspace-forking/lib/mapping/dependent-value-store' +import { + annotateForkClearedRefSourceLiveness, + collectForkClearedRefCandidates, +} from '@/ee/workspace-forking/lib/promote/cleared-refs' +import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' +import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls' +import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' + +interface SyncDetailsInput { + workspaceId: string + otherWorkspaceId: string + direction: 'push' | 'pull' +} + +export const getWorkspaceSyncDetails = defineForkUseCase({ + operation: forkOperations.syncPreview, + bothSides: true, + edge: true, + async execute({ + input, + context, + }: { + input: SyncDetailsInput + context: import('@/ee/workspace-forking/application/authorized-fork-use-case').ForkApplicationContext + }) { + const { workspaceId: id, direction } = input + const auth = { + edge: context.edge!, + sourceWorkspaceId: direction === 'push' ? id : input.otherWorkspaceId, + targetWorkspaceId: direction === 'push' ? input.otherWorkspaceId : id, + } + const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates( + auth.sourceWorkspaceId + ) + const plan = await computeForkPromotePlan({ + executor: db, + edge: auth.edge, + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + direction, + deployedSourceWorkflows: deployedWorkflows, + sourceStates, + }) + + // Resolve dependent-reconfig target block ids through the SAME persisted block map the + // sync will use, so a re-pick the modal keys by target block id lands on the block the + // promote actually writes (on push that's the parent's original id, not a derived one). + const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId + const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId) + const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) + + // Stored dependent values are the source of truth for what each selector is set to. Overlay + // them as each field's currentValue so the modal pre-fills what the user actually saved. + // Before the FIRST sync populates the store (fork-create seeds mappings but no dependent + // values), the fallback is the TARGET's own configured value (loaded from its draft) - never + // the source's, which would overwrite the target's selection. The stored read spans EVERY + // plan target: a create-mode (never-synced) workflow's deterministic target id is what the + // first sync will use, so values pre-configured for it in the mapping editor pre-fill here + // too. The draft read stays replace-scoped (creates have no target draft to fall back to). + const replaceTargetIds = plan.items + .filter((item) => item.mode === 'replace') + .map((item) => item.targetWorkflowId) + const allTargetIds = plan.items.map((item) => item.targetWorkflowId) + const [ + storedValues, + targetDraftByWorkflow, + sourceCandidates, + sourceWorkflowRows, + excludedSourceWorkflows, + ] = await Promise.all([ + loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), + loadTargetDraftSubBlocks(db, replaceTargetIds), + // Source resource labels (per kind) + workflow names, for the cleared-ref list's display. + listForkResourceCandidates(db, auth.sourceWorkspaceId), + db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), + // Deployed-but-excluded source workflows, so the preview can show what a sync skips. + listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId), + ]) + const storedByKey = new Map( + storedValues.map((entry) => [ + forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey), + entry.value, + ]) + ) + + // Source block subBlocks keyed by their resolved target identity, so the first-sync draft + // fallback can identity-check a nested tool against the SOURCE dependent tool it came from - + // an index alone may point at a different tool in the target draft, whose value isn't the + // dependent's. Read structurally (only each subblock's `value`), so the in-memory state's + // blocks pass without a cast. + const sourceBlocksByTarget = new Map>>() + for (const item of plan.items) { + if (item.mode !== 'replace') continue + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + const byBlock = new Map>() + for (const [sourceBlockId, block] of Object.entries(state.blocks)) { + byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {}) + } + sourceBlocksByTarget.set(item.targetWorkflowId, byBlock) + } + + // Replace-target fields pre-fill from the store, falling back to the TARGET's own draft + // value before the first sync populates the store (never the source's, which would + // overwrite the target's selection). Create-target fields (never-synced workflows) + // pre-fill from the store, falling back to the SOURCE value the collector emitted - + // that's exactly what the first sync copies verbatim, so the pre-fill is honest and + // configuring it ahead of the first sync is possible (the deterministic target ids + // already exist). + // Custom-block inputs join the same list: repointing a block makes every one of its + // inputs reconfigurable (see `collectForkCustomBlockReconfigs`), and they store, pre-fill, + // gate Sync, and apply through this identical channel. + const customBlockReconfigs = await collectForkCustomBlockReconfigs({ + items: plan.items, + sourceStates, + resolveTargetBlockId: resolveBlockId, + resolve: plan.resolver, + targetWorkspaceId: plan.targetWorkspaceId, + }) + + const dependentReconfigs = [ + ...customBlockReconfigs.map((field) => ({ + ...field, + currentValue: + storedByKey.get( + forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) + ) ?? field.currentValue, + })), + ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({ + ...field, + currentValue: + storedByKey.get( + forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) + ) ?? + readTargetDraftDependentValue( + targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks, + sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId), + field.subBlockKey + ), + })), + ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map( + (field) => ({ + ...field, + currentValue: + storedByKey.get( + forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) + ) ?? field.currentValue, + }) + ), + ] + + // References this sync will blank in the target (per block/field), for the pre-sync cleared-ref + // list. Labels resolve from the source candidate lists + workflow names loaded above. + const sourceLabels = new Map() + for (const [kind, candidates] of Object.entries(sourceCandidates)) { + for (const candidate of candidates) + sourceLabels.set(`${kind}:${candidate.id}`, candidate.label) + } + const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name])) + // Annotate each reference-cause entry's source liveness so the client can phrase the blocker + // reason (a deleted source can't be copied - it must be mapped to a live target resource). + const clearedRefs = await annotateForkClearedRefSourceLiveness( + db, + auth.sourceWorkspaceId, + collectForkClearedRefCandidates({ + items: plan.items, + sourceStates, + resolver: plan.resolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + sourceLabels, + sourceWorkflowNames, + }) + ) + + // Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request + // URL again" case, surfaced as an editable pairing before the overwrite instead of discovered + // after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the + // promote call, where the same plan is rebuilt and validated against them. + const triggerPlan = buildForkTriggerPlan({ + items: plan.items, + sourceStates, + resolveBlockId, + targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds), + }) + // The RAW retiring set, not the default resolution: the client derives which of these actually + // stop being served from the picks the user is making right now, so the heads-up and the + // overwrite confirm can never disagree with the Trigger URLs rows. + const retiringTriggerUrls = triggerPlan.retiring.map((row) => ({ + workflowName: row.workflowName, + path: row.path, + })) + // Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just + // the decisions, so the section reads as a standing statement of each URL rather than an alert. + // + // A trigger with neither is deliberately absent: whether a block will serve a URL at all is + // only knowable from its webhook row, and a schedule / chat / manual / poller trigger never + // gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative + // flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on + // 345 including `slack_oauth`, which routes by `routingKey` with a NULL path. + const triggerMappings = triggerPlan.slots + .filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0) + .map((slot) => ({ + sourceBlockId: slot.sourceBlockId, + blockName: slot.blockName, + workflowName: slot.workflowName, + ownPath: slot.ownPath, + adoptablePaths: slot.adoptablePaths, + defaultAdoptPath: slot.defaultAdoptPath, + })) + + const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + required: reference.required, + blockName: reference.blockName, + }) + + // Orient the mapping around the workspace the modal is open in (`id`): show the + // caller's workflow name first, the sync partner's second, so renames are legible. + const currentIsSource = auth.sourceWorkspaceId === id + const workflows = [ + ...plan.items.map((item) => { + if (item.mode === 'create') { + // The target inherits the source's name, so both sides read the same. + return { + action: 'create' as const, + currentName: item.sourceMeta.name, + otherName: item.sourceMeta.name, + } + } + const targetName = item.targetName ?? item.sourceMeta.name + return { + action: 'update' as const, + currentName: currentIsSource ? item.sourceMeta.name : targetName, + otherName: currentIsSource ? targetName : item.sourceMeta.name, + } + }), + ...plan.archivedTargets.map((target) => ({ + action: 'archive' as const, + currentName: target.name, + otherName: target.name, + })), + ] + + return { + sourceWorkspaceId: auth.sourceWorkspaceId, + targetWorkspaceId: auth.targetWorkspaceId, + willUpdate: plan.willUpdate, + willCreate: plan.willCreate, + willArchive: plan.willArchive, + workflows, + excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name), + excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name), + unmappedRequired: plan.unmappedRequired.map(toRef), + unmappedOptional: plan.unmappedOptional.map(toRef), + mcpReauthServerIds: plan.mcpReauthServerIds, + inlineSecretSources: plan.inlineSecretSources, + dependentReconfigs, + resourceUsages: collectForkResourceUsages(plan.items, sourceStates), + copyableUnmapped: plan.copyableUnmapped, + clearedRefs, + retiringTriggerUrls, + triggerMappings, + } + }, +}) diff --git a/apps/sim/ee/workspace-forking/application/validate-bindings.ts b/apps/sim/ee/workspace-forking/application/validate-bindings.ts new file mode 100644 index 00000000000..5c855f3dd0f --- /dev/null +++ b/apps/sim/ee/workspace-forking/application/validate-bindings.ts @@ -0,0 +1,45 @@ +import type { Principal } from '@sim/auth/principal' +import type { DbOrTx } from '@/lib/db/types' +import { + authorizeWorkflowBindingCredentials, + validateWorkflowBindingTargets, +} from '@/lib/workflows/references/binding-targets' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** Validates the destination bindings used by both persisted and request-local mapping entries. */ +export async function validateForkWorkflowBindings(params: { + executor: DbOrTx + workspaceId: string + sourceStates: Map + items: Array<{ sourceWorkflowId: string }> + resolve: ForkReferenceResolver + principal?: Principal + lock?: boolean +}): Promise { + for (const item of params.items) { + const state = params.sourceStates.get(item.sourceWorkflowId) + if (!state) continue + const manifest = buildWorkflowReferenceManifest(state.blocks) + const bindings = manifest.references.flatMap((reference) => { + if (reference.kind === 'workflow') return [] + const targetId = params.resolve(reference.kind, reference.sourceId) + return targetId + ? reference.occurrences.map((occurrence) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + targetId, + required: reference.required, + occurrence, + })) + : [] + }) + const plan = { state, sourceState: state, manifest, bindings, unresolvedBindings: [] } + if (params.principal) + await authorizeWorkflowBindingCredentials(params.principal, params.workspaceId, plan) + await validateWorkflowBindingTargets(params.executor, params.workspaceId, plan, { + lock: params.lock, + }) + } +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index 8ad52d3521c..1b1b80c01c8 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -61,6 +61,7 @@ export type MappableMappingKind = Exclude = { + sandbox: { label: 'Sandboxes', order: 12 }, credential: { label: 'Credentials', order: 0 }, 'env-var': { label: 'Secrets', order: 1 }, table: { label: 'Tables', order: 2 }, diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index bd591e6e5ea..920aa61e2ac 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -34,6 +34,7 @@ vi.mock('@/tools/params', () => ({ formatParameterLabel: (label: string) => label, })) +import type { ForkRemapKind } from '@/lib/workflows/references/remap-references' import { getBlock } from '@/blocks/registry' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { @@ -43,7 +44,6 @@ import { rewriteDeploymentVersionState, } from '@/ee/workspace-forking/lib/copy/cleanup-failed' import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' -import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => ({ name: 'Knowledge', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts index a28aed94fe8..538f4e22667 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.ts @@ -13,16 +13,16 @@ import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, exists, gt, inArray, isNull, notExists, sql } from 'drizzle-orm' import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' import { invalidateDeployedStateCache } from '@/lib/workflows/persistence/utils' +import { + clearDependentsOnRemap, + type ForkRemapKind, + remapForkSubBlocks, +} from '@/lib/workflows/references/remap-references' import { FORK_DOCUMENT_ID_PATTERN, type ForkFailedResource, } from '@/ee/workspace-forking/lib/copy/copy-resources' import type { ForkCopyResolver } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' -import { - clearDependentsOnRemap, - type ForkRemapKind, - remapForkSubBlocks, -} from '@/ee/workspace-forking/lib/remap/remap-references' const logger = createLogger('WorkspaceForkCleanupFailed') diff --git a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.test.ts b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.test.ts index 59b513de74d..03856d8b5b2 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.test.ts @@ -34,6 +34,10 @@ import { } from '@/ee/workspace-forking/lib/copy/content-copy-runner' import type { BlobCopyTask } from '@/ee/workspace-forking/lib/copy/copy-files' import type { ForkContentPlan } from '@/ee/workspace-forking/lib/copy/copy-resources' +import { + ForkCopyCheckpointError, + ForkCopyContinuation, +} from '@/ee/workspace-forking/lib/copy/progress' describe('serializeContentRefMaps', () => { it('converts each map to a record and drops empty maps', () => { @@ -173,4 +177,23 @@ describe('runForkContentCopy', () => { }) ) }) + + it.each([ + new ForkCopyContinuation('continue from checkpoint'), + new ForkCopyCheckpointError('lease lost while checkpointing'), + ])('does not record interrupted copy work as failed: %s', async (error) => { + mockCopyForkResourceContent.mockRejectedValueOnce(error) + await expect(runForkContentCopy(payload())).rejects.toBe(error) + expect(mockFinishBackgroundWork).not.toHaveBeenCalled() + expect(mockExecuteForkFileBlobCopies).not.toHaveBeenCalled() + }) + + it('does not let an expired lease overwrite background work status', async () => { + const error = new Error('lease expired') + mockCopyForkResourceContent.mockRejectedValueOnce(error) + await expect(runForkContentCopy(payload(), { signal: AbortSignal.abort(error) })).rejects.toBe( + error + ) + expect(mockFinishBackgroundWork).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts index fb2af4ac93a..0b3551d6126 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/content-copy-runner.ts @@ -12,6 +12,10 @@ import type { ForkFailedResource, } from '@/ee/workspace-forking/lib/copy/copy-resources' import { copyForkResourceContent } from '@/ee/workspace-forking/lib/copy/copy-resources' +import { + type ForkCopyControl, + rethrowForkCopyInterruption, +} from '@/ee/workspace-forking/lib/copy/progress' import type { ForkContentRefMaps } from '@/ee/workspace-forking/lib/remap/remap-content-refs' const logger = createLogger('WorkspaceForkContentCopy') @@ -129,16 +133,35 @@ function deserializeContentRefMaps( /** * Copy the heavy fork content after the fork transaction has committed: table * rows, KB documents + embeddings (keyset-paginated), and file blobs. Best-effort - * and idempotency-unsafe (per-row inserts use fresh ids), so it must run at most - * once - never blindly retried. Per-resource failures are counted (not thrown), so + * with deterministic target identities and optional durable copy checkpoints. Per-resource failures are counted (not thrown), so * the run finishes `completed_with_warnings` rather than failing the whole copy. */ -export async function runForkContentCopy(payload: ForkContentCopyPayload): Promise { +export async function runForkContentCopy( + payload: ForkContentCopyPayload, + options?: { + preserveSnapshots?: boolean + signal?: AbortSignal + control?: ForkCopyControl + onComplete?: (result: { copied: number; failed: number }) => Promise + } +): Promise { const { contentPlan, blobTasks, statusId, requestId } = payload try { const contentRefMaps = deserializeContentRefMaps(payload.contentRefMaps) - const resourceCounts = await copyForkResourceContent({ contentPlan, contentRefMaps, requestId }) - const fileCounts = await executeForkFileBlobCopies(blobTasks, requestId, contentRefMaps) + const resourceCounts = await copyForkResourceContent({ + contentPlan, + contentRefMaps, + requestId, + control: options?.control ?? { signal: options?.signal }, + }) + options?.signal?.throwIfAborted() + const fileCounts = await executeForkFileBlobCopies( + blobTasks, + requestId, + contentRefMaps, + options?.control ?? { signal: options?.signal } + ) + options?.signal?.throwIfAborted() // A resource whose content fill failed leaves a dangling reference: a table/KB/doc placeholder // its workflows still point at, or a `file-upload` whose copied blob is missing. Clear those // references (draft + deployed versions) and drop the table/KB/doc placeholder so nothing @@ -147,12 +170,14 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi kind: 'file', childKey, })) - const { cleared, clearingFailed } = await clearFailedForkResourceReferences({ - childWorkspaceId: contentPlan.childWorkspaceId, - failures: [...resourceCounts.failures, ...fileFailures], - deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds, - requestId, - }) + const { cleared, clearingFailed } = options?.preserveSnapshots + ? { cleared: 0, clearingFailed: false } + : await clearFailedForkResourceReferences({ + childWorkspaceId: contentPlan.childWorkspaceId, + failures: [...resourceCounts.failures, ...fileFailures], + deployedTargetWorkflowIds: payload.deployedTargetWorkflowIds, + requestId, + }) const copied = resourceCounts.copied + fileCounts.copied const failed = resourceCounts.failed + fileCounts.failed if (statusId) { @@ -170,7 +195,9 @@ export async function runForkContentCopy(payload: ForkContentCopyPayload): Promi }, }) } + await options?.onComplete?.({ copied, failed }) } catch (error) { + rethrowForkCopyInterruption(error, options?.control ?? { signal: options?.signal }) if (statusId) { await finishBackgroundWork(db, statusId, { status: 'failed', diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index 7e9a16b3755..e5bbfd5069a 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -23,6 +23,12 @@ import { import { getWorkspaceFileSize, type StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' +import { + assertForkCopyActive, + completeForkCopyResource, + type ForkCopyControl, + rethrowForkCopyInterruption, +} from '@/ee/workspace-forking/lib/copy/progress' import { type ForkContentRefMaps, rewriteForkContentRefs, @@ -265,16 +271,19 @@ export async function planForkFileCopies(params: { export async function executeForkFileBlobCopies( blobTasks: BlobCopyTask[], requestId = 'unknown', - contentRefMaps?: ForkContentRefMaps + contentRefMaps?: ForkContentRefMaps, + control?: ForkCopyControl ): Promise<{ copied: number; failed: number; failedTargetKeys: string[] }> { let copied = 0 const failedTargetKeys: string[] = [] for (let offset = 0; offset < blobTasks.length; offset += BLOB_COPY_PAGE) { + assertForkCopyActive(control) const taskPage = blobTasks.slice(offset, offset + BLOB_COPY_PAGE) let finalizedById: Map try { finalizedById = await getFinalizedFileCopies(taskPage) } catch (error) { + rethrowForkCopyInterruption(error, control) for (const task of taskPage) { failedTargetKeys.push(task.targetKey) logger.warn(`[${requestId}] Failed to check copied file replay state`, { @@ -286,6 +295,11 @@ export async function executeForkFileBlobCopies( } for (const task of taskPage) { + assertForkCopyActive(control) + if (control?.progress?.completed.includes(`file:${task.targetFileId}`)) { + copied++ + continue + } let uploadedThisAttempt = false try { const finalized = finalizedById.get(task.targetFileId) @@ -293,6 +307,7 @@ export async function executeForkFileBlobCopies( if (finalized.key !== task.targetKey || finalized.workspaceId !== task.workspaceId) { throw new Error(`Conflicting target metadata for copied file ${task.targetFileId}`) } + await completeForkCopyResource(control, `file:${task.targetFileId}`) copied += 1 continue } @@ -310,6 +325,7 @@ export async function executeForkFileBlobCopies( const rewritten = rewriteForkContentRefs(text, contentRefMaps) if (rewritten !== text) body = Buffer.from(rewritten, 'utf8') } catch (error) { + rethrowForkCopyInterruption(error, control) logger.warn( `[${requestId}] Failed to rewrite markdown blob content; copying raw bytes`, { @@ -319,6 +335,7 @@ export async function executeForkFileBlobCopies( ) } } + assertForkCopyActive(control) await uploadFile({ file: body, fileName: task.fileName, @@ -336,11 +353,14 @@ export async function executeForkFileBlobCopies( uploadedThisAttempt = true } + assertForkCopyActive(control) const billingContext = await resolveStorageBillingContext(task.workspaceId) const targetOriginalName = await resolveTargetOriginalName(task) const targetDisplayName = targetOriginalName === task.fileName ? task.displayName : targetOriginalName + assertForkCopyActive(control) await db.transaction(async (tx) => { + assertForkCopyActive(control) const [inserted] = await tx .insert(workspaceFiles) .values({ @@ -423,6 +443,7 @@ export async function executeForkFileBlobCopies( ) await incrementStorageUsageForBillingContextInTx(tx, billingContext, task.size) }) + await completeForkCopyResource(control, `file:${task.targetFileId}`) copied += 1 if (targetOriginalName !== task.fileName) { logger.warn(`[${requestId}] Copied file renamed to avoid a target name collision`, { @@ -433,6 +454,7 @@ export async function executeForkFileBlobCopies( }) } } catch (error) { + rethrowForkCopyInterruption(error, control) failedTargetKeys.push(task.targetKey) logger.warn(`[${requestId}] Failed to copy file blob during fork`, { targetKey: task.targetKey, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index 0a60ffc2ec3..d245c2c22f3 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -12,6 +12,7 @@ import { storageServiceMock, storageServiceMockFns, } from '@sim/testing' +import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance' import { @@ -50,13 +51,17 @@ vi.mock('@/ee/workspace-forking/lib/mapping/mapping-store', () => ({ })) import type { DbOrTx } from '@/lib/db/types' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' import { copyForkResourceContainers, copyForkResourceContent, type ForkContentPlan, planForkMappedKbDocumentCopies, } from '@/ee/workspace-forking/lib/copy/copy-resources' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import { + ForkCopyContinuation, + type ForkCopyProgress, +} from '@/ee/workspace-forking/lib/copy/progress' function basePlan(overrides: Partial = {}): ForkContentPlan { return { @@ -996,6 +1001,169 @@ describe('copyForkResourceContent', () => { ) }) + it('drains in-flight document copies before yielding a knowledge base continuation', async () => { + const secondSource = { ...sourceDoc, id: 'doc-2', storageKey: 'kb/second-source' } + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc, secondSource]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([secondSource]) + .mockResolvedValueOnce([]) + let releaseCopy = () => {} + let reportInterrupted = () => {} + const copying = new Promise((resolve) => { + releaseCopy = resolve + }) + const interrupted = new Promise((resolve) => { + reportInterrupted = resolve + }) + const continuation = new ForkCopyContinuation('resume the next attempt') + storageServiceMockFns.mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key === 'kb/second-source') { + reportInterrupted() + throw continuation + } + await copying + return Buffer.from('blob-bytes') + }) + let settled = false + const outcome = copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + }).then( + (result) => { + settled = true + return result + }, + (error: unknown) => { + settled = true + return error + } + ) + await interrupted + await sleep(1) + try { + expect(settled).toBe(false) + expect(mockDecrementStorageUsageInTx).not.toHaveBeenCalled() + } finally { + releaseCopy() + } + expect(await outcome).toBe(continuation) + expect(mockIncrementStorageUsageInTx).toHaveBeenCalledTimes(1) + expect(mockDecrementStorageUsageInTx).not.toHaveBeenCalled() + }) + + it('refuses to resume retained embeddings after the source is reprocessed', async () => { + const source = { ...sourceDoc, processingQueueToken: 'generation-1' } + dbChainMockFns.limit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([source]) + .mockResolvedValueOnce([source]) + .mockResolvedValueOnce([ + { + id: 'embedding-1', + documentId: 'doc-1', + content: 'old content', + secretProvenanceVersion: null, + }, + ]) + const progress: ForkCopyProgress = { completed: [], tables: {}, embeddings: {} } + const control = { + progress, + checkpoint: vi.fn(async () => { + if (progress.embeddings['child-doc-1']?.afterId) { + throw new ForkCopyContinuation('continue after first page') + } + }), + } + await expect( + copyForkResourceContent({ contentPlan: mappedDocumentPlan(), control }) + ).rejects.toBeInstanceOf(ForkCopyContinuation) + expect(progress.embeddings['child-doc-1']).toMatchObject({ + afterId: 'embedding-1', + knowledgeBaseId: 'existing-target-kb', + sourceRevision: expect.any(String), + }) + const prior = structuredClone(progress) + const copiedWrites = dbChainMockFns.values.mock.calls.length + queueMappedDocumentCopy({ ...source, processingQueueToken: 'generation-2' }) + const result = await copyForkResourceContent({ contentPlan: mappedDocumentPlan(), control }) + expect(result).toEqual({ + copied: 0, + failed: 1, + failures: [{ kind: 'knowledge-document', childId: 'child-doc-1' }], + }) + expect(progress).toEqual(prior) + expect(dbChainMockFns.values.mock.calls).toHaveLength(copiedWrites) + expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledTimes(1) + }) + + it('retains source-bound document cursors when another document rolls the knowledge base back', async () => { + const secondSource = { ...sourceDoc, id: 'doc-2', storageKey: 'kb/second-source' } + dbChainMockFns.limit + .mockResolvedValueOnce([sourceDoc, secondSource]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([sourceDoc]) + .mockResolvedValueOnce([secondSource]) + .mockResolvedValueOnce([]) + storageServiceMockFns.mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { + if (key === 'kb/second-source') throw new Error('source blob unavailable') + return Buffer.from('blob-bytes') + }) + const progress: ForkCopyProgress = { completed: [], tables: {}, embeddings: {} } + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + control: { progress, checkpoint: vi.fn(async () => {}) }, + }) + expect(result.failed).toBe(1) + expect(mockIncrementStorageUsageInTx).toHaveBeenCalledTimes(1) + expect(mockDecrementStorageUsageInTx).toHaveBeenCalledTimes(1) + expect(progress.completed).toEqual([]) + expect(Object.values(progress.embeddings)).toEqual([ + { afterId: null, knowledgeBaseId: 'child-kb', sourceRevision: expect.any(String) }, + { afterId: null, knowledgeBaseId: 'child-kb', sourceRevision: expect.any(String) }, + ]) + }) + + it('stops after a lease expires during download before creating target storage', async () => { + queueMappedDocumentCopy() + const controller = new AbortController() + storageServiceMockFns.mockDownloadFile.mockImplementationOnce(async () => { + controller.abort(new Error('lease expired during download')) + return Buffer.from('blob-bytes') + }) + await expect( + copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + control: { signal: controller.signal }, + }) + ).rejects.toThrow('lease expired during download') + expect(mockRecordKnowledgeBaseFileOwnership).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() + }) + + it('does not activate a document after its lease expires while waiting for the knowledge base lock', async () => { + queueMappedDocumentCopy() + const controller = new AbortController() + dbChainMockFns.for.mockImplementationOnce(async () => { + controller.abort(new Error('lease expired while waiting for lock')) + return [{ workspaceId: 'child-ws' }] + }) + await expect( + copyForkResourceContent({ + contentPlan: mappedDocumentPlan(), + control: { signal: controller.signal }, + }) + ).rejects.toThrow('lease expired while waiting for lock') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockIncrementStorageUsageInTx).not.toHaveBeenCalled() + }) + it('U-docs: fills a document copied into an existing target KB (blob re-key + placeholder update)', async () => { queueMappedDocumentCopy() diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index 4143e7d1663..c5cd463b170 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -58,7 +58,7 @@ import { } from '@/lib/knowledge/secret-provenance' import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants' import { generateTableId } from '@/lib/table/ids' -import { nKeysBetween } from '@/lib/table/order-key' +import { keyBetween } from '@/lib/table/order-key' import { classifyTableRowSecretProvenanceForCopy, TABLE_ROW_SECRET_PROVENANCE_VERSION, @@ -75,7 +75,18 @@ import { recordKnowledgeBaseFileOwnership, } from '@/lib/uploads/server/metadata' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { + type ForkReferenceResolver, + rewriteEnvRefsInText, +} from '@/lib/workflows/references/remap-references' import { resolveForkFolderMapping } from '@/ee/workspace-forking/lib/copy/copy-workflows' +import { + assertForkCopyActive, + bindForkCopyEmbeddings, + completeForkCopyResource, + type ForkCopyControl, + rethrowForkCopyInterruption, +} from '@/ee/workspace-forking/lib/copy/progress' import { deleteCopiedResourceMappingsByTargets, type ForkMappingUpsert, @@ -88,10 +99,6 @@ import { rewriteForkContentRefs, rewriteForkResourceUrls, } from '@/ee/workspace-forking/lib/remap/remap-content-refs' -import { - type ForkReferenceResolver, - rewriteEnvRefsInText, -} from '@/ee/workspace-forking/lib/remap/remap-references' import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups' const logger = createLogger('WorkspaceForkCopyResources') @@ -126,7 +133,7 @@ const KB_DOCUMENT_COPY_CONCURRENCY = 5 export const FORK_DOCUMENT_ID_PATTERN = '^fork_document_[0-9a-f]{40}$' function deriveCopyIdentity( - kind: 'document' | 'embedding', + kind: 'document' | 'embedding' | 'table_row', targetId: string, sourceId: string ): string { @@ -1073,8 +1080,9 @@ export async function copyForkResourceContent(params: { /** In-content reference maps for rewriting copied skill bodies post-commit (best-effort). */ contentRefMaps?: ForkContentRefMaps requestId?: string + control?: ForkCopyControl }): Promise<{ copied: number; failed: number; failures: ForkFailedResource[] }> { - const { contentPlan, contentRefMaps, requestId = 'unknown' } = params + const { contentPlan, contentRefMaps, control, requestId = 'unknown' } = params const { childWorkspaceId, userId } = contentPlan let copiedResources = 0 @@ -1092,6 +1100,7 @@ export async function copyForkResourceContent(params: { * rethrown, since the caller is already reporting the document as failed. */ const dropCopiedDocumentMapping = async (childDocumentId: string): Promise => { + assertForkCopyActive(control) const mappingContext = contentPlan.documentMappingContext if (!mappingContext) return try { @@ -1102,6 +1111,7 @@ export async function copyForkResourceContent(params: { targets: [{ resourceType: 'knowledge_document', resourceId: childDocumentId }], }) } catch (mappingCleanupError) { + rethrowForkCopyInterruption(mappingCleanupError, control) logger.error(`[${requestId}] Failed to clean mapping for a failed copied document`, { childDocumentId, error: getErrorMessage(mappingCleanupError), @@ -1149,6 +1159,7 @@ export async function copyForkResourceContent(params: { } return staleChildIds } catch (error) { + rethrowForkCopyInterruption(error, control) logger.error( `[${requestId}] Failed to reconcile fork placeholders planned for connector-managed documents`, { @@ -1187,6 +1198,7 @@ export async function copyForkResourceContent(params: { skipped, }) } catch (error) { + rethrowForkCopyInterruption(error, control) logger.warn(`[${requestId}] Failed to count the documents a copied knowledge base skipped`, { sourceKnowledgeBaseId: kb.sourceId, error: getErrorMessage(error), @@ -1195,25 +1207,30 @@ export async function copyForkResourceContent(params: { } for (const table of contentPlan.tables) { + assertForkCopyActive(control) + if (control?.progress?.completed.includes(`table:${table.childId}`)) { + copiedResources++ + continue + } try { - let copied = 0 - let afterId: string | null = null + const saved = control?.progress?.tables[table.childId] + let copied = saved?.copied ?? 0 + let afterId: string | null = saved?.afterId ?? null // `order_key` is nullable, and spreading `...row` would inherit NULLs into a // brand-new tableId that the one-shot backfill script-migration never revisits // (it snapshots the pending set up front) — leaving rows the keyset pager has to // special-case forever. Mint keys for the unkeyed ones instead. They sort last in // the source (NULLS LAST, id tiebreak) and this loop pages by id, so consuming a // pre-generated run appended after the source's max key preserves visual order. - const [{ maxKey = null, unkeyed = 0 } = {}] = await db + const [{ maxKey = null } = {}] = await db .select({ maxKey: sql`max(${userTableRows.orderKey})`, - unkeyed: sql`count(*) filter (where ${userTableRows.orderKey} is null)`, }) .from(userTableRows) .where(eq(userTableRows.tableId, table.sourceId)) - const mintedKeys = unkeyed > 0 ? nKeysBetween(maxKey, null, Number(unkeyed)) : [] - let mintedIdx = 0 + let lastMintedKey = saved?.lastOrderKey ?? maxKey for (;;) { + assertForkCopyActive(control) const where: SQL | undefined = afterId === null ? eq(userTableRows.tableId, table.sourceId) @@ -1249,10 +1266,10 @@ export async function copyForkResourceContent(params: { return { row: { ...row, - id: generateId(), + id: deriveCopyIdentity('table_row', table.childId, row.id), tableId: table.childId, workspaceId: childWorkspaceId, - orderKey: row.orderKey ?? mintedKeys[mintedIdx++] ?? null, + orderKey: row.orderKey ?? (lastMintedKey = keyBetween(lastMintedKey, null)), secretProvenanceVersion: classification.mode === 'legacy' ? null : TABLE_ROW_SECRET_PROVENANCE_VERSION, // Repoint resource-chip URLs in cell data at the child copies (no-op when no maps). @@ -1262,7 +1279,11 @@ export async function copyForkResourceContent(params: { } }) await db.transaction(async (trx) => { - await trx.insert(userTableRows).values(copiedRows.map((copy) => copy.row)) + assertForkCopyActive(control) + await trx + .insert(userTableRows) + .values(copiedRows.map((copy) => copy.row)) + .onConflictDoNothing({ target: userTableRows.id }) const provenanceRows = copiedRows.flatMap((copy) => copy.provenance ? [ @@ -1277,19 +1298,28 @@ export async function copyForkResourceContent(params: { : [] ) if (provenanceRows.length > 0) { - await trx.insert(userTableRowSecretProvenance).values(provenanceRows) + await trx + .insert(userTableRowSecretProvenance) + .values(provenanceRows) + .onConflictDoNothing({ target: userTableRowSecretProvenance.rowId }) } }) copied += rows.length afterId = rows[rows.length - 1].row.id + if (control?.progress && control.checkpoint) { + control.progress.tables[table.childId] = { afterId, copied, lastOrderKey: lastMintedKey } + await control.checkpoint(control.progress) + } if (rows.length < PROVENANCE_CONTENT_PAGE) break } await db .update(userTableDefinitions) .set({ rowCount: copied }) .where(eq(userTableDefinitions.id, table.childId)) + await completeForkCopyResource(control, `table:${table.childId}`) copiedResources += 1 } catch (error) { + rethrowForkCopyInterruption(error, control) failedResources += 1 failures.push({ kind: 'table', childId: table.childId }) logger.warn(`[${requestId}] Failed to copy table rows during fork`, { @@ -1300,6 +1330,11 @@ export async function copyForkResourceContent(params: { } for (const kb of contentPlan.knowledgeBases) { + assertForkCopyActive(control) + if (control?.progress?.completed.includes(`knowledge-base:${kb.childId}`)) { + copiedResources++ + continue + } try { await logSkippedConnectorDocuments(kb) for (const childDocumentId of await reconcileStalePlannedDocuments(kb)) { @@ -1308,6 +1343,7 @@ export async function copyForkResourceContent(params: { } let afterDocId: string | null = null for (;;) { + assertForkCopyActive(control) // Only copy LIVE documents - exclude soft-deleted and archived rows, matching // how the rest of the KB system treats them as gone (chunks/tags/search filter // both). A fork must not resurrect documents removed from the source base. @@ -1350,10 +1386,7 @@ export async function copyForkResourceContent(params: { const documentsToCopy = documentCopies.filter( ({ childDocumentId }) => !activeTargetDocumentIds.has(childDocumentId) ) - // Copy the page's documents with bounded concurrency. The mapper never rejects - // (it captures its error), so all in-flight work settles before this resolves and a - // captured error is rethrown after to keep the KB ALL-OR-NOTHING (any failed doc fails - // the whole KB -> cleanup below). + /** Drain every worker before propagating interruptions or rolling back the knowledge base. */ if (documentsToCopy.length > 0) { const resolvedBillingContext = await getBillingContext() const docErrors = await mapWithConcurrency( @@ -1363,6 +1396,7 @@ export async function copyForkResourceContent(params: { try { await copyKbDocument({ source, + control, childDocumentId, childKnowledgeBaseId: kb.childId, childWorkspaceId, @@ -1375,12 +1409,14 @@ export async function copyForkResourceContent(params: { } } ) + for (const error of docErrors) rethrowForkCopyInterruption(error, control) const docError = docErrors.find((error) => error != null) if (docError) throw docError } const mappingContext = contentPlan.documentMappingContext if (mappingContext) { await db.transaction(async (tx) => { + assertForkCopyActive(control) await persistCopiedResourceMappings({ executor: tx, edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId, @@ -1392,16 +1428,20 @@ export async function copyForkResourceContent(params: { childResourceId: childDocumentId, })), }) + assertForkCopyActive(control) }) } afterDocId = docs[docs.length - 1].id if (docs.length < CONTENT_PAGE) break } + await completeForkCopyResource(control, `knowledge-base:${kb.childId}`) copiedResources += 1 } catch (error) { + rethrowForkCopyInterruption(error, control) try { - await rollbackCopiedKbDocuments(kb.childId, childWorkspaceId) + await rollbackCopiedKbDocuments(kb.childId, childWorkspaceId, control) } catch (rollbackError) { + rethrowForkCopyInterruption(rollbackError, control) logger.error(`[${requestId}] Failed to roll back copied KB storage accounting`, { childKnowledgeBaseId: kb.childId, error: getErrorMessage(rollbackError), @@ -1415,9 +1455,11 @@ export async function copyForkResourceContent(params: { try { await deleteFailedKnowledgeBaseDocumentMappings( kb.childId, - contentPlan.documentMappingContext + contentPlan.documentMappingContext, + control ) } catch (mappingCleanupError) { + rethrowForkCopyInterruption(mappingCleanupError, control) logger.error(`[${requestId}] Failed to clean mappings for a failed copied KB`, { childKnowledgeBaseId: kb.childId, error: getErrorMessage(mappingCleanupError), @@ -1443,12 +1485,18 @@ export async function copyForkResourceContent(params: { // embeddings cascade) and clears its `document-selector` references - the existing KB and its // own documents are never touched. for (const docEntry of contentPlan.documents) { + assertForkCopyActive(control) + if (control?.progress?.completed.includes(`document:${docEntry.childDocId}`)) { + copiedResources++ + continue + } try { const active = await isActiveTargetDocument({ childDocumentId: docEntry.childDocId, childKnowledgeBaseId: docEntry.childKnowledgeBaseId, }) if (active) { + await completeForkCopyResource(control, `document:${docEntry.childDocId}`) copiedResources += 1 continue } @@ -1478,14 +1526,17 @@ export async function copyForkResourceContent(params: { const resolvedBillingContext = await getBillingContext() await copyKbDocument({ source, + control, childDocumentId: docEntry.childDocId, childKnowledgeBaseId: docEntry.childKnowledgeBaseId, childWorkspaceId, userId, billingContext: resolvedBillingContext, }) + await completeForkCopyResource(control, `document:${docEntry.childDocId}`) copiedResources += 1 } catch (error) { + rethrowForkCopyInterruption(error, control) await dropCopiedDocumentMapping(docEntry.childDocId) failedResources += 1 failures.push({ kind: 'knowledge-document', childId: docEntry.childDocId }) @@ -1505,6 +1556,7 @@ export async function copyForkResourceContent(params: { const childSkillIds = contentPlan.skills.map((entry) => entry.childId) let afterId: string | null = null for (;;) { + assertForkCopyActive(control) const where: SQL | undefined = afterId === null ? inArray(skill.id, childSkillIds) @@ -1521,11 +1573,15 @@ export async function copyForkResourceContent(params: { // logged and the body keeps its source links rather than failing a resource. await mapWithConcurrency(rows, SKILL_REWRITE_CONCURRENCY, async (row): Promise => { try { + assertForkCopyActive(control) + if (control?.progress?.completed.includes(`skill:${row.id}`)) return const rewritten = rewriteForkContentRefs(row.content, contentRefMaps) if (rewritten !== row.content) { await db.update(skill).set({ content: rewritten }).where(eq(skill.id, row.id)) } + await completeForkCopyResource(control, `skill:${row.id}`) } catch (error) { + rethrowForkCopyInterruption(error, control) logger.warn( `[${requestId}] Failed to rewrite copied skill content; keeping source links`, { @@ -1612,6 +1668,7 @@ async function ensureKbDocumentPlaceholder( * the transaction that activates it receives a row from `RETURNING` and charges. */ async function finalizeKbDocument(params: { + control?: ForkCopyControl childDocumentId: string childKnowledgeBaseId: string billingContext: StorageBillingContext @@ -1637,6 +1694,7 @@ async function finalizeKbDocument(params: { .from(knowledgeBase) .where(eq(knowledgeBase.id, childKnowledgeBaseId)) .for('update') + assertForkCopyActive(params.control) if (!lockedKnowledgeBase) { throw new Error(`Copied document knowledge base ${childKnowledgeBaseId} is missing`) } @@ -1705,6 +1763,7 @@ async function finalizeKbDocument(params: { tx ) } + assertForkCopyActive(params.control) return active.storageKey } @@ -1722,6 +1781,7 @@ async function finalizeKbDocument(params: { } await incrementStorageUsageForBillingContextInTx(tx, billingContext, bytes) + assertForkCopyActive(params.control) return fileOwnership?.key ?? null }) } @@ -1732,6 +1792,7 @@ async function finalizeKbDocument(params: { * step, so a failed copy leaves only a non-billable archived placeholder. */ async function copyKbDocument(params: { + control?: ForkCopyControl source: typeof document.$inferSelect childDocumentId: string childKnowledgeBaseId: string @@ -1747,6 +1808,7 @@ async function copyKbDocument(params: { userId, billingContext, } = params + assertForkCopyActive(params.control) const sourceSecretContext = await loadKnowledgeDocumentDurableSecretProvenance(source.id) const sourceSnapshotHash = hashDurableSecretProvenanceValue( createKnowledgeDocumentSourceValue(source) @@ -1755,10 +1817,42 @@ async function copyKbDocument(params: { if (!sourceSnapshotHash || sourceSnapshotHash !== provenanceSnapshotHash) { throw new Error(`Knowledge document ${source.id} changed while preparing its fork copy`) } + const sourceRevision = sha256Hex( + JSON.stringify({ + sourceSnapshotHash, + storageKey: source.storageKey, + fileSize: source.fileSize, + chunkCount: source.chunkCount, + processingStatus: source.processingStatus, + processingQueueToken: source.processingQueueToken, + processingStartedAt: source.processingStartedAt, + processingCompletedAt: source.processingCompletedAt, + }) + ) + const afterEmbeddingId = await bindForkCopyEmbeddings( + params.control, + childDocumentId, + childKnowledgeBaseId, + sourceRevision + ) await ensureKbDocumentPlaceholder(source, childDocumentId, childKnowledgeBaseId, userId) - const blob = await copyKbDocumentBlob(source, childWorkspaceId, userId, childDocumentId) - await copyDocumentEmbeddings(source.id, childDocumentId, childKnowledgeBaseId) + assertForkCopyActive(params.control) + const blob = await copyKbDocumentBlob( + source, + childWorkspaceId, + userId, + childDocumentId, + params.control + ) + assertForkCopyActive(params.control) + await copyDocumentEmbeddings( + source.id, + childDocumentId, + childKnowledgeBaseId, + afterEmbeddingId, + params.control + ) const copiedValues = { ...omit(source, ['id', 'knowledgeBaseId']), knowledgeBaseId: childKnowledgeBaseId, @@ -1773,7 +1867,9 @@ async function copyKbDocument(params: { acl: [WORKSPACE_ACCESS_TOKEN], } const copiedSource = createKnowledgeDocumentSourceValue(copiedValues) + assertForkCopyActive(params.control) const finalizedStorageKey = await finalizeKbDocument({ + control: params.control, childDocumentId, childKnowledgeBaseId, billingContext, @@ -1803,6 +1899,7 @@ async function copyKbDocument(params: { : {}), }) if (blob && finalizedStorageKey !== blob.storageKey) { + assertForkCopyActive(params.control) try { await deleteFile({ key: blob.storageKey, context: 'knowledge-base' }) } catch (error) { @@ -1823,7 +1920,8 @@ async function copyKbDocument(params: { */ async function rollbackCopiedKbDocuments( childKnowledgeBaseId: string, - childWorkspaceId: string + childWorkspaceId: string, + control?: ForkCopyControl ): Promise { const billingContext = await resolveStorageBillingContext(childWorkspaceId) await db.transaction(async (tx) => { @@ -1832,6 +1930,7 @@ async function rollbackCopiedKbDocuments( .from(knowledgeBase) .where(eq(knowledgeBase.id, childKnowledgeBaseId)) .for('update') + assertForkCopyActive(control) if (!lockedKnowledgeBase || lockedKnowledgeBase.workspaceId !== childWorkspaceId) { throw new Error( `Copied knowledge base ${childKnowledgeBaseId} moved from workspace ${childWorkspaceId}; refusing stale storage rollback` @@ -1851,6 +1950,7 @@ async function rollbackCopiedKbDocuments( ) ) const bytes = Number(usage?.total ?? 0) + assertForkCopyActive(control) await decrementStorageUsageForBillingContextInTx(tx, billingContext, bytes) await tx .update(workspaceFiles) @@ -1891,6 +1991,7 @@ async function rollbackCopiedKbDocuments( isNull(document.archivedAt) ) ) + assertForkCopyActive(control) }) } @@ -1902,10 +2003,12 @@ async function rollbackCopiedKbDocuments( */ async function deleteFailedKnowledgeBaseDocumentMappings( childKnowledgeBaseId: string, - mappingContext: ForkDocumentMappingContext + mappingContext: ForkDocumentMappingContext, + control?: ForkCopyControl ): Promise { let afterId: string | null = null for (;;) { + assertForkCopyActive(control) const rows = await db .select({ id: document.id }) .from(document) @@ -1924,6 +2027,7 @@ async function deleteFailedKnowledgeBaseDocumentMappings( .orderBy(asc(document.id)) .limit(CONTENT_PAGE) if (rows.length === 0) break + assertForkCopyActive(control) await deleteCopiedResourceMappingsByTargets({ executor: db, edgeChildWorkspaceId: mappingContext.edgeChildWorkspaceId, @@ -1941,10 +2045,13 @@ async function deleteFailedKnowledgeBaseDocumentMappings( async function copyDocumentEmbeddings( sourceDocumentId: string, childDocumentId: string, - childKnowledgeBaseId: string + childKnowledgeBaseId: string, + afterEmbeddingId: string | null, + control?: ForkCopyControl ): Promise { - let afterId: string | null = null + let afterId = afterEmbeddingId for (;;) { + assertForkCopyActive(control) const where: SQL | undefined = afterId === null ? eq(embedding.documentId, sourceDocumentId) @@ -1975,6 +2082,7 @@ async function copyDocumentEmbeddings( documentId: childDocumentId, knowledgeBaseId: childKnowledgeBaseId, })) + assertForkCopyActive(control) await db.insert(embedding).values(targetRows).onConflictDoNothing({ target: embedding.id }) const targetSidecars = rows.flatMap((row, index) => { if (row.secretProvenanceVersion !== 1) return [] @@ -2006,6 +2114,10 @@ async function copyDocumentEmbeddings( .onConflictDoNothing({ target: embeddingSecretProvenance.embeddingId }) } afterId = rows[rows.length - 1].id + if (control?.progress && control.checkpoint) { + control.progress.embeddings[childDocumentId].afterId = afterId + await control.checkpoint(control.progress) + } if (rows.length < PROVENANCE_CONTENT_PAGE) break } } @@ -2028,7 +2140,8 @@ async function copyKbDocumentBlob( doc: { storageKey: string | null; filename: string; mimeType: string; fileSize: number }, childWorkspaceId: string, userId: string, - childDocumentId: string + childDocumentId: string, + control?: ForkCopyControl ): Promise<{ storageKey: string; fileUrl: string } | null> { if (!doc.storageKey) return null const buffer = await downloadFile({ @@ -2036,6 +2149,7 @@ async function copyKbDocumentBlob( context: 'knowledge-base', maxBytes: MAX_FILE_SIZE, }) + assertForkCopyActive(control) const targetKey = deriveKbDocumentStorageKey(childDocumentId, sha256Hex(buffer)) await recordKnowledgeBaseFileOwnership({ key: targetKey, @@ -2046,6 +2160,7 @@ async function copyKbDocumentBlob( size: doc.fileSize, }) const existing = await headObject(targetKey, 'knowledge-base') + assertForkCopyActive(control) if (!existing) { await uploadFile({ file: buffer, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index 14bc09b0aca..5334049aaee 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -5,12 +5,17 @@ import { describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { FolderCollectionFullError } from '@/lib/folders/errors' +import { createForkSubBlockTransform } from '@/lib/workflows/references/remap-references' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const { mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({ mockSaveWorkflowToNormalizedTables: vi.fn(), })) vi.mock('@/lib/workflows/persistence/utils', () => ({ + CREDENTIAL_SUBBLOCK_IDS: new Set(['credential']), saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) @@ -374,6 +379,104 @@ describe('copyWorkflowStateIntoTarget folder fallback', () => { }) }) +describe('copyWorkflowStateIntoTarget source tool identities', () => { + it.each([false, true])( + 'applies source-indexed choices before pruning optional tools (serialized=%s)', + async (serialized) => { + const tools = [ + { type: 'custom-tool', customToolId: 'deleted-custom-tool' }, + { type: 'workflow_input', params: { workflowId: 'uncopied-workflow' } }, + { + type: 'mcp', + title: 'First', + params: { serverId: 'source-server', toolName: 'old-first' }, + }, + { + type: 'mcp', + title: 'Second', + params: { serverId: 'source-server', toolName: 'old-second' }, + }, + ] + const sourceState: WorkflowState = { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: serialized ? JSON.stringify(tools) : tools, + }, + }, + data: { canonicalModes: { '2:credential': 'advanced' } }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } + const config = { subBlocks: [{ id: 'tools', type: 'tool-input' }] } as BlockConfig + await vi.mocked(getBlock).withImplementation( + (type) => (type === 'agent' ? config : undefined), + async () => { + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + await copyWorkflowStateIntoTarget({ + tx: { insert: () => ({ values: () => Promise.resolve() }) } as unknown as DbOrTx, + targetWorkflowId: 'wf-child', + targetWorkspaceId: 'ws-child', + userId: 'user', + mode: 'create', + now: new Date('2026-09-09'), + sourceState, + sourceMeta: { name: 'Agent mapping', description: null, folderId: null, sortOrder: 0 }, + workflowIdMap: new Map([['wf-source', 'wf-child']]), + folderIdMap: new Map(), + nameRegistry: buildWorkflowNameRegistry([]), + resolveBlockId: () => 'target-agent', + transformSubBlocks: createForkSubBlockTransform((kind) => + kind === 'mcp-server' ? 'target-server' : null + ), + dependentOverrides: new Map([ + [ + 'target-agent', + new Map([ + ['tools[2].toolName', 'chosen-first'], + ['tools[3].toolName', 'chosen-second'], + ]), + ], + ]), + }) + } + ) + const saved = mockSaveWorkflowToNormalizedTables.mock.calls.at(-1)![1] as WorkflowState + const value = saved.blocks['target-agent'].subBlocks.tools.value + const copied = typeof value === 'string' ? JSON.parse(value) : value + expect(copied).toEqual([ + expect.objectContaining({ + title: 'First', + params: { serverId: 'target-server', toolName: 'chosen-first' }, + }), + expect.objectContaining({ + title: 'Second', + params: { serverId: 'target-server', toolName: 'chosen-second' }, + }), + ]) + expect(saved.blocks['target-agent'].data?.canonicalModes).toEqual({ + '0:credential': 'advanced', + }) + expect(sourceState.blocks.agent.subBlocks.tools.value).toEqual( + serialized ? JSON.stringify(tools) : tools + ) + } + ) +}) + describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => { it( "persists a transform's reindexed canonicalModes on the copied block, and uses that " + diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index fc9fadc38ef..56edbebcaeb 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -16,18 +16,19 @@ import { sanitizeSubBlocksForDuplicate, } from '@/lib/workflows/persistence/remap-internal-ids' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' -import { - deriveForkBlockId, - type ForkBlockIdResolver, -} from '@/ee/workspace-forking/lib/remap/block-identity' +import { finalizeBlockToolPositions } from '@/lib/workflows/references/finalize-tool-positions' import { applyDependentOverrides, collectClearedDependents, type NeedsConfigurationField, replaceCustomBlockInputs, type SubBlockTransform, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { + deriveForkBlockId, + type ForkBlockIdResolver, +} from '@/ee/workspace-forking/lib/remap/block-identity' import type { BlockData, BlockState, @@ -570,7 +571,8 @@ export async function copyWorkflowStateIntoTarget( activeCanonicalModes = next updatedData = { ...updatedData, canonicalModes: next } as BlockData }, - blockTriggerMode + blockTriggerMode, + true ) } if (varIdMapping.size > 0) { @@ -580,6 +582,7 @@ export async function copyWorkflowStateIntoTarget( // rather than leave them pointing at the source workspace. subBlocks = remapWorkflowReferencesInSubBlocks(subBlocks, workflowIdMap, { clearUnmapped: true, + preserveToolIndices: true, canonicalModes: activeCanonicalModes, }) subBlocks = remapConditionIdsInSubBlocks( @@ -600,24 +603,6 @@ export async function copyWorkflowStateIntoTarget( subBlocks = applyDependentOverrides(subBlocks, block.type, blockOverrides) } - // Dependents the TARGET had configured that the parent change cleared and nothing - // restored: the target must re-pick required ones (promote skips this workflow's - // redeploy) and is told about optional ones. Keyed on the target draft so a field the - // source carried but the target never set isn't flagged. - if (mode === 'replace' && targetCurrent) { - clearedDependents.push( - ...collectClearedDependents( - block.type, - newBlockId, - block.name, - targetCurrent.subBlocks, - subBlocks, - activeCanonicalModes, - blockTriggerMode - ) - ) - } - const nextBlockType = transformBlockType ? transformBlockType(block.type, { id: oldBlockId, name: block.name }) : block.type @@ -639,6 +624,21 @@ export async function copyWorkflowStateIntoTarget( subBlocks: subBlocks as unknown as Record, data: updatedData, } + finalizeBlockToolPositions(newBlocks[newBlockId]) + /** Compare the final tool positions with the target draft when reporting cleared selections. */ + if (mode === 'replace' && targetCurrent) { + clearedDependents.push( + ...collectClearedDependents( + block.type, + newBlockId, + block.name, + targetCurrent.subBlocks, + subBlocks, + newBlocks[newBlockId].data?.canonicalModes, + blockTriggerMode + ) + ) + } } const newEdges = sourceState.edges.flatMap((edge) => { diff --git a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts index 32f6228b804..9bb10647c97 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/deploy-bridge.ts @@ -3,7 +3,11 @@ import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, exists, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' +import { + loadDeployedWorkflowState, + loadWorkflowDeploymentVersionState, + materializeDeploymentState, +} from '@/lib/workflows/persistence/utils' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' import { isInternalTriggerProvider, isPollingWebhookProvider } from '@/triggers/constants' @@ -21,6 +25,9 @@ const logger = createLogger('WorkspaceForkDeployBridge') */ export const MAX_FORK_DEPLOYED_WORKFLOWS = 1000 +/** Aggregate serialized source state admitted before any graph materialization. */ +export const MAX_FORK_STATE_BYTES = 64 * 1024 * 1024 + export interface DeployedWorkflowSummary { id: string name: string @@ -76,6 +83,7 @@ export async function listDeployedWorkflows( ) ) ) + .limit(MAX_FORK_DEPLOYED_WORKFLOWS + 1) } /** @@ -171,6 +179,7 @@ export async function getActiveDeploymentVersionNumbers( export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promise<{ deployedWorkflows: DeployedWorkflowSummary[] sourceStates: Map + sourceVersionIds: Map }> { const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId) // Fail fast on the cheap count before loading any heavy state into memory. @@ -180,21 +189,117 @@ export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promi 400 ) } + if (deployedWorkflows.length > 0) { + const [size] = await db + .select({ + bytes: sql`coalesce(sum(octet_length(${workflowDeploymentVersion.state}::text)), 0)`, + }) + .from(workflowDeploymentVersion) + .where( + and( + inArray( + workflowDeploymentVersion.workflowId, + deployedWorkflows.map((workflow) => workflow.id) + ), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + if (Number(size?.bytes ?? 0) > MAX_FORK_STATE_BYTES) { + throw new ForkError( + `Deployed workflow states exceed the ${MAX_FORK_STATE_BYTES} byte fork/sync limit`, + 413 + ) + } + } + const versions = deployedWorkflows.length + ? await db + .select({ + id: workflowDeploymentVersion.id, + workflowId: workflowDeploymentVersion.workflowId, + bytes: sql`octet_length(${workflowDeploymentVersion.state}::text)`, + digest: sql`md5(${workflowDeploymentVersion.state}::text)`, + }) + .from(workflowDeploymentVersion) + .where( + and( + inArray( + workflowDeploymentVersion.workflowId, + deployedWorkflows.map((item) => item.id) + ), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + : [] + if (versions.reduce((total, row) => total + Number(row.bytes), 0) > MAX_FORK_STATE_BYTES) + throw new ForkError('Deployed workflow states exceed the aggregate byte limit', 413) + const sourceVersionIds = new Map( + versions.map((version) => [version.workflowId, { id: version.id, digest: version.digest }]) + ) + if ( + sourceVersionIds.size !== deployedWorkflows.length || + versions.length !== sourceVersionIds.size + ) + throw new ForkError('Source deployments changed during loading; request a new preview', 409) // Read states in bounded-concurrency batches instead of one serial await per workflow: // serial cost is O(workflows) round trips (this also runs on the diff preview, refetched // while the sync modal is open). The cap keeps concurrent global-pool checkouts well // under the pool max even at the workflow ceiling, and this runs BEFORE any transaction. const sourceStates = new Map() + let materializedBytes = 0 const READ_CONCURRENCY = 5 for (let i = 0; i < deployedWorkflows.length; i += READ_CONCURRENCY) { const batch = deployedWorkflows.slice(i, i + READ_CONCURRENCY) - const states = await Promise.all(batch.map((wf) => readDeployedState(wf.id, sourceWorkspaceId))) + const states = await Promise.all( + batch.map((wf) => + readAdmittedSourceState(wf.id, sourceWorkspaceId, sourceVersionIds.get(wf.id)!) + ) + ) batch.forEach((wf, index) => { const state = states[index] - if (state) sourceStates.set(wf.id, state) + if (state) { + materializedBytes += Buffer.byteLength(JSON.stringify(state), 'utf8') + if (materializedBytes > MAX_FORK_STATE_BYTES) { + throw new ForkError( + `Deployed workflow states exceed the ${MAX_FORK_STATE_BYTES} byte fork/sync limit`, + 413 + ) + } + sourceStates.set(wf.id, state) + } }) } - return { deployedWorkflows, sourceStates } + return { deployedWorkflows, sourceStates, sourceVersionIds } +} + +/** Materializes only the bounded snapshot selected during admission, bypassing historical caches. */ +async function readAdmittedSourceState( + workflowId: string, + workspaceId: string, + expected: { id: string; digest: string } +): Promise { + const [version] = await db + .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.id, expected.id), + eq(workflowDeploymentVersion.workflowId, workflowId), + sql`md5(${workflowDeploymentVersion.state}::text) = ${expected.digest}` + ) + ) + .limit(1) + if (!version) + throw new ForkError('Source deployment changed during loading; request a new preview', 409) + const data = await materializeDeploymentState(workflowId, version, workspaceId, db, { + cache: false, + }) + return { + blocks: data.blocks, + edges: data.edges, + loops: data.loops, + parallels: data.parallels, + variables: (data.variables ?? {}) as Record, + } } /** @@ -206,19 +311,22 @@ export async function loadSourceDeployedStates(sourceWorkspaceId: string): Promi */ export async function readDeployedState( workflowId: string, - workspaceId: string + workspaceId: string, + deploymentVersionId?: string ): Promise { // This reads the (unchanged) SOURCE workspace on the global pool. Callers like // promote run it inside their transaction, so escape the tx context: the read // must not join the promote's transaction (and the tripwire forbids global-pool // queries inside a tx). Outside a transaction this is a no-op. return runOutsideTransactionContext(async () => { - const version = await getActiveDeploymentVersionNumber(db, workflowId) + const version = deploymentVersionId ?? (await getActiveDeploymentVersionNumber(db, workflowId)) if (version == null) { logger.warn('No active deployment for workflow during fork/promote', { workflowId }) return null } - const data = await loadDeployedWorkflowState(workflowId, workspaceId) + const data = deploymentVersionId + ? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId) + : await loadDeployedWorkflowState(workflowId, workspaceId) return { blocks: data.blocks, edges: data.edges, diff --git a/apps/sim/ee/workspace-forking/lib/copy/progress.test.ts b/apps/sim/ee/workspace-forking/lib/copy/progress.test.ts new file mode 100644 index 00000000000..c4bda87a122 --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/copy/progress.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + bindForkCopyEmbeddings, + completeForkCopyResource, + ForkCopyContinuation, + type ForkCopyControl, + type ForkCopyProgress, +} from '@/ee/workspace-forking/lib/copy/progress' + +function control(): ForkCopyControl & { progress: ForkCopyProgress } { + return { + progress: { completed: [], tables: {}, embeddings: {} }, + checkpoint: vi.fn(async () => {}), + } +} + +describe('fork copy checkpoints', () => { + it('binds a document generation durably before returning its first embedding cursor', async () => { + const copy = control() + await expect(bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision-1')).resolves.toBeNull() + expect(copy.checkpoint).toHaveBeenCalledWith({ + completed: [], + tables: {}, + embeddings: { + doc: { afterId: null, knowledgeBaseId: 'kb', sourceRevision: 'revision-1' }, + }, + }) + copy.progress.embeddings.doc.afterId = 'chunk-8' + await expect(bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision-1')).resolves.toBe('chunk-8') + expect(copy.checkpoint).toHaveBeenCalledTimes(1) + }) + + it('refuses a changed source generation without discarding retained embeddings', async () => { + const copy = control() + await bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision-1') + copy.progress.embeddings.doc.afterId = 'chunk-8' + const prior = structuredClone(copy.progress) + await expect(bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision-2')).rejects.toThrow( + 'changed after copying began' + ) + expect(copy.progress).toEqual(prior) + expect(copy.checkpoint).toHaveBeenCalledTimes(1) + }) + + it('retains knowledge base cursors until the whole base completes and prunes only that base', async () => { + const copy = control() + await bindForkCopyEmbeddings(copy, 'first', 'kb', 'revision-1') + await bindForkCopyEmbeddings(copy, 'second', 'kb', 'revision-2') + await bindForkCopyEmbeddings(copy, 'other', 'other-kb', 'revision-3') + await completeForkCopyResource(copy, 'knowledge-base:kb') + expect(copy.progress).toEqual({ + completed: ['knowledge-base:kb'], + tables: {}, + embeddings: { + other: { afterId: null, knowledgeBaseId: 'other-kb', sourceRevision: 'revision-3' }, + }, + }) + }) + + it('prunes table and standalone document cursors with their completion markers', async () => { + const copy = control() + copy.progress.tables.table = { afterId: 'row', copied: 8, lastOrderKey: 'a' } + await bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision-1') + await completeForkCopyResource(copy, 'table:table') + await completeForkCopyResource(copy, 'document:doc') + await completeForkCopyResource(copy, 'document:doc') + expect(copy.progress).toEqual({ + completed: ['table:table', 'document:doc'], + tables: {}, + embeddings: {}, + }) + }) + + it('does not start a new document when continuation or cancellation is due', async () => { + const copy = control() + copy.deadlineAt = Date.now() + await expect(bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision')).rejects.toBeInstanceOf( + ForkCopyContinuation + ) + copy.signal = AbortSignal.abort(new Error('lease lost')) + await expect(bindForkCopyEmbeddings(copy, 'doc', 'kb', 'revision')).rejects.toThrow( + 'lease lost' + ) + expect(copy.checkpoint).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/copy/progress.ts b/apps/sim/ee/workspace-forking/lib/copy/progress.ts new file mode 100644 index 00000000000..f42d3cc976c --- /dev/null +++ b/apps/sim/ee/workspace-forking/lib/copy/progress.ts @@ -0,0 +1,77 @@ +export interface ForkCopyProgress { + completed: string[] + tables: Record + embeddings: Record< + string, + { afterId: string | null; sourceRevision: string; knowledgeBaseId: string } + > +} + +export interface ForkCopyControl { + signal?: AbortSignal + deadlineAt?: number + progress?: ForkCopyProgress + checkpoint?: (progress: ForkCopyProgress) => Promise +} + +/** A continuation after durable progress does not consume the outbox retry budget. */ +export class ForkCopyContinuation extends Error {} +export class ForkCopyCheckpointError extends Error {} + +export function assertForkCopyActive(control?: ForkCopyControl): void { + control?.signal?.throwIfAborted() + if (control?.checkpoint && control.deadlineAt && Date.now() >= control.deadlineAt - 30_000) { + throw new ForkCopyContinuation('Continue resource copying from its checkpoint') + } +} + +export function rethrowForkCopyInterruption(error: unknown, control?: ForkCopyControl): void { + control?.signal?.throwIfAborted() + if (error instanceof ForkCopyContinuation) throw error + if (error instanceof ForkCopyCheckpointError) throw error +} + +/** Records the source generation before any resumable embedding writes. */ +export async function bindForkCopyEmbeddings( + control: ForkCopyControl | undefined, + childDocumentId: string, + knowledgeBaseId: string, + sourceRevision: string +): Promise { + assertForkCopyActive(control) + if (!control?.progress || !control.checkpoint) return null + const cursor = control.progress.embeddings[childDocumentId] + if (cursor) { + if (cursor.sourceRevision !== sourceRevision || cursor.knowledgeBaseId !== knowledgeBaseId) { + throw new Error( + `The source of copied document ${childDocumentId} changed after copying began` + ) + } + return cursor.afterId + } + control.progress.embeddings[childDocumentId] = { + afterId: null, + sourceRevision, + knowledgeBaseId, + } + await control.checkpoint(control.progress) + return null +} + +export async function completeForkCopyResource( + control: ForkCopyControl | undefined, + key: string +): Promise { + control?.signal?.throwIfAborted() + if (!control?.progress || !control.checkpoint) return + if (!control.progress.completed.includes(key)) control.progress.completed.push(key) + if (key.startsWith('table:')) delete control.progress.tables[key.slice('table:'.length)] + if (key.startsWith('document:')) delete control.progress.embeddings[key.slice('document:'.length)] + if (key.startsWith('knowledge-base:')) { + const knowledgeBaseId = key.slice('knowledge-base:'.length) + for (const [documentId, cursor] of Object.entries(control.progress.embeddings)) { + if (cursor.knowledgeBaseId === knowledgeBaseId) delete control.progress.embeddings[documentId] + } + } + await control.checkpoint(control.progress) +} diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts index 810ff130af2..1debcc80bf7 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.test.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.test.ts @@ -85,7 +85,7 @@ vi.mock('@/ee/workspace-forking/lib/remap/fork-bootstrap', () => ({ createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks), createForkBlockTypeTransform: vi.fn(() => (blockType: string) => blockType), })) -vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({ +vi.mock('@/lib/workflows/references/reference-scan', () => ({ collectReferencedDocumentIds: vi.fn(() => new Set()), collectReferencedFileFolderPaths: mockCollectReferencedFileFolderPaths, })) diff --git a/apps/sim/ee/workspace-forking/lib/create-fork.ts b/apps/sim/ee/workspace-forking/lib/create-fork.ts index 13454b0e8ed..6b26e233324 100644 --- a/apps/sim/ee/workspace-forking/lib/create-fork.ts +++ b/apps/sim/ee/workspace-forking/lib/create-fork.ts @@ -6,11 +6,30 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import type { Workspace } from '@/lib/api/contracts/workspaces' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + collectReferencedDocumentIds, + collectReferencedFileFolderPaths, +} from '@/lib/workflows/references/reference-scan' +import type { ForkRemapKind } from '@/lib/workflows/references/remap-references' +import { + findWorkspaceOperationReceipt, + insertWorkspaceOperationReceipt, + lockWorkspaceOperationRequest, + type WorkspaceOperationReport, +} from '@/lib/workspaces/operations/receipts' import type { WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import type { WorkspaceCreationPolicy } from '@/lib/workspaces/policy' import { WORKSPACE_MODE } from '@/lib/workspaces/policy' +import { enqueueDurableForkContent } from '@/ee/workspace-forking/application/content-outbox' +import { + assertForkPreviewFresh, + assertForkSourceVersions, + type ForkMutationAdmission, + lockForkRevision, +} from '@/ee/workspace-forking/application/revision' import { finishBackgroundWork, startBackgroundWork, @@ -53,11 +72,6 @@ import { } from '@/ee/workspace-forking/lib/mapping/mapping-store' import { deriveForkBlockId } from '@/ee/workspace-forking/lib/remap/block-identity' import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' -import { - collectReferencedDocumentIds, - collectReferencedFileFolderPaths, -} from '@/ee/workspace-forking/lib/remap/reference-scan' -import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' const logger = createLogger('WorkspaceForkCreate') @@ -85,6 +99,7 @@ const EMPTY_SELECTION: ForkResourceSelection = { } export interface CreateForkParams { + admission?: ForkMutationAdmission source: WorkspaceWithOwner policy: WorkspaceCreationPolicy userId: string @@ -96,6 +111,8 @@ export interface CreateForkParams { } export interface CreateForkResult { + operation?: WorkspaceOperationReport + replayed?: boolean /** Full child workspace row so callers can merge it into the workspace-list cache. */ workspace: Workspace workflowsCopied: number @@ -123,6 +140,17 @@ const FORK_KIND_TO_RESOURCE_TYPE: Partial { const { source, policy, userId, requestId = 'unknown' } = params + const admission = params.admission + if (admission) { + const receipt = await findWorkspaceOperationReceipt( + db, + admission.workspaceId, + admission.requestId, + admission.requestHash + ) + if (receipt?.forkResult) return { ...receipt.forkResult, operation: receipt, replayed: true } + await assertForkPreviewFresh(db, { sourceWorkspaceId: source.id }, admission) + } const selection = params.selection ?? EMPTY_SELECTION const childName = params.name?.trim() || `${source.name} (fork)` const childWorkspaceId = generateId() @@ -142,7 +170,9 @@ export async function createFork(params: CreateForkParams): Promise { + const transaction = await db.transaction(async (tx) => { await setForkLockTimeout(tx) + if (admission) { + await lockWorkspaceOperationRequest(tx, admission.workspaceId, admission.requestId) + const receipt = await findWorkspaceOperationReceipt( + tx, + admission.workspaceId, + admission.requestId, + admission.requestHash + ) + if (receipt?.forkResult) return { replay: receipt } + await lockForkRevision(tx, { sourceWorkspaceId: source.id }) + await assertForkPreviewFresh(tx, { sourceWorkspaceId: source.id }, admission) + await assertForkSourceVersions(tx, source.id, sourceVersionIds) + } /** * The lock alone is not enough: `policy.organizationId` was captured by * `assertCanFork` BEFORE this transaction, so a re-home that commits in @@ -508,25 +551,69 @@ export async function createFork(params: CreateForkParams): Promise { +export async function assertForkingEnabled(organizationId: string | null): Promise { if (!isBillingEnabled && !isForkingEnabled) { throw new ForkError('Workspace forking is not enabled on this deployment', 404) } @@ -25,7 +26,10 @@ async function assertForkingEnabled(organizationId: string | null, userId: strin ? await isOrganizationOnEnterprisePlan(organizationId) : false if (!hasEnterprise) { - throw new ForkError('Workspace forking is available on Enterprise plans only', 403) + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', + 'Workspace forking is available on Enterprise plans only' + ) } } } @@ -41,7 +45,7 @@ export async function isForkingAvailableForWorkspace( userId: string ): Promise { try { - await assertForkingEnabled(organizationId, userId) + await assertForkingEnabled(organizationId) return true } catch { return false @@ -71,7 +75,7 @@ async function requireWorkspace( if (!access.exists || !access.workspace) { throw new ForkError('Workspace not found', 404) } - await assertForkingEnabled(access.workspace.organizationId, userId) + await assertForkingEnabled(access.workspace.organizationId) return { workspace: access.workspace, canAdmin: access.canAdmin } } diff --git a/apps/sim/ee/workspace-forking/lib/mapping/cascade.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/cascade.test.ts index 0a5ce7b7434..3b150f868c9 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/cascade.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/cascade.test.ts @@ -3,11 +3,11 @@ */ import { describe, expect, it } from 'vitest' import type { DbOrTx } from '@/lib/db/types' -import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade' import type { ForkReference, ForkReferenceResolver, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade' /** Executor that returns the queued result arrays in the order queries are issued. */ function queuedExecutor(results: unknown[][]): DbOrTx { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/cascade.ts b/apps/sim/ee/workspace-forking/lib/mapping/cascade.ts index d74125ea94e..0767e560b49 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/cascade.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/cascade.ts @@ -5,7 +5,7 @@ import { ENV_REF_PATTERN, type ForkReference, type ForkReferenceResolver, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' function extractEnvKeys(text: string): string[] { const keys = new Set() diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.test.ts index d94f7f2d90b..ce8d3b6b1a0 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' import { type ForkDependentValue, forkDependentValueKey, @@ -10,7 +11,6 @@ import { reconcileForkDependentValues, translateForkDependentValues, } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' describe('forkDependentValueKey', () => { it('builds a stable triple key', () => { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.ts index cc4335f24db..679c061285c 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-value-store.ts @@ -2,7 +2,7 @@ import { workspaceForkDependentValue } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' /** One stored dependent-field value for an edge. */ export interface ForkDependentValue { diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.test.ts index 35f7d3a2789..bffac8ea6e8 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { ForkRemapKind } from '@/lib/workflows/references/remap-references' const { mockFilterExisting, @@ -28,7 +28,7 @@ const { mockDetectCascade: vi.fn(), })) -vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({ +vi.mock('@/lib/workflows/references/resources', () => ({ listForkResourceCandidates: mockListCandidates, classifyCredentialResourceType: mockClassifyCredential, getWorkspaceEnvKeys: mockGetEnvKeys, @@ -47,16 +47,17 @@ vi.mock('@/ee/workspace-forking/lib/mapping/cascade', () => ({ detectForkCascadeReferences: mockDetectCascade, })) -vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({ +vi.mock('@/lib/workflows/references/remap-references', () => ({ scanWorkflowReferences: mockScanWorkflowReferences, })) -vi.mock('@/ee/workspace-forking/lib/remap/reference-scan', () => ({ +vi.mock('@/lib/workflows/references/reference-scan', () => ({ toScannerBlocks: vi.fn((state: unknown) => state), })) import { workflow, workspaceForkResourceMap } from '@sim/db/schema' import { queueTableRows, resetDbChainMock } from '@sim/testing' +import type { ForkResourceCandidate } from '@/lib/workflows/references/resources' import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz' import { findDuplicateTargetEntry, @@ -64,7 +65,6 @@ import { suggestTarget, validateForkMappingTargets, } from '@/ee/workspace-forking/lib/mapping/mapping-service' -import type { ForkResourceCandidate } from '@/ee/workspace-forking/lib/mapping/resources' type ExistingByKind = Partial>> diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts index 3995dda0987..89dd63725aa 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts @@ -3,6 +3,22 @@ import { workflow } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' +import { toScannerBlocks } from '@/lib/workflows/references/reference-scan' +import { + type ForkReference, + type ForkRemapKind, + scanWorkflowReferences, +} from '@/lib/workflows/references/remap-references' +import { + CANDIDATE_LIMIT, + classifyCredentialResourceType, + type ForkResourceCandidate, + filterExistingForkTargets, + getCredentialProvidersByIds, + getWorkspaceEnvKeys, + listForkResourceCandidates, + loadForkResourceLabels, +} from '@/lib/workflows/references/resources' import { listDeployedWorkflows, readDeployedState, @@ -13,29 +29,14 @@ import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/ import { buildForkResolver, deleteEdgeMappingsByChildResources, + type ForkMappingRow, type ForkResourceType, getEdgeMappingRows, nonCredentialForkKindToResourceType, resourceTypeToForkKind, upsertEdgeMappings, } from '@/ee/workspace-forking/lib/mapping/mapping-store' -import { - CANDIDATE_LIMIT, - classifyCredentialResourceType, - type ForkResourceCandidate, - filterExistingForkTargets, - getCredentialProvidersByIds, - getWorkspaceEnvKeys, - listForkResourceCandidates, - loadForkResourceLabels, -} from '@/ee/workspace-forking/lib/mapping/resources' import { resolveForkExcludedTargetId } from '@/ee/workspace-forking/lib/promote/promote-plan' -import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan' -import { - type ForkReference, - type ForkRemapKind, - scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' interface ForkMappingViewParams { edge: ForkEdge @@ -299,6 +300,50 @@ export interface ApplyForkMappingEntry { targetId: string | null } +/** Applies the same canonical upsert rules in memory so previews never persist proposed mappings. */ +export function overlayForkMappingEntries( + rows: readonly ForkMappingRow[], + edge: ForkEdge, + sourceWorkspaceId: string, + entries: readonly ApplyForkMappingEntry[] +): ForkMappingRow[] { + if (sourceWorkspaceId !== edge.parentWorkspaceId && sourceWorkspaceId !== edge.childWorkspaceId) + throw new ForkError('Mapping source must belong to the fork edge', 400) + const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId + const sourceKeys = new Set() + for (const entry of entries) { + const key = JSON.stringify([entry.resourceType, entry.sourceId]) + if (sourceKeys.has(key)) throw new ForkError('Duplicate source mapping instruction', 400) + sourceKeys.add(key) + } + if (!sourceIsParent && findDuplicateTargetEntry([...entries])) + throw new ForkError('Each parent target can map from only one child source', 400) + let next = [...rows] + for (const entry of entries) { + next = next.filter( + (row) => + row.resourceType !== entry.resourceType || + (sourceIsParent ? row.parentResourceId : row.childResourceId) !== entry.sourceId + ) + } + for (const entry of entries) { + if (!sourceIsParent && entry.targetId === null) continue + const parentResourceId = sourceIsParent ? entry.sourceId : entry.targetId! + const childResourceId = sourceIsParent ? entry.targetId : entry.sourceId + next = next.filter( + (row) => row.resourceType !== entry.resourceType || row.parentResourceId !== parentResourceId + ) + next.push({ + id: JSON.stringify([entry.resourceType, parentResourceId]), + childWorkspaceId: edge.childWorkspaceId, + resourceType: entry.resourceType, + parentResourceId, + childResourceId, + }) + } + return next +} + /** * The first target two distinct sources are mapped to (same resourceType + targetId, * different sourceId), or null when every target is used by at most one source. Cleared @@ -329,19 +374,21 @@ export function findDuplicateTargetEntry( } /** - * Persist mapping edits for a direction. Pull maps a parent source to a child - * target; push maps a child source to a parent target (clearing a push mapping - * deletes the row). + * Persist source-to-target edits in canonical parent/child storage orientation. + * Direction is a caller-relative action and never determines edge orientation. */ export async function applyForkMappingEntries( tx: DbOrTx, edge: ForkEdge, userId: string, - direction: 'push' | 'pull', + sourceWorkspaceId: string, entries: ApplyForkMappingEntry[] ): Promise { + if (sourceWorkspaceId !== edge.parentWorkspaceId && sourceWorkspaceId !== edge.childWorkspaceId) { + throw new ForkError('Mapping source must belong to the fork edge', 400) + } if (entries.length === 0) return 0 - if (direction === 'pull') { + if (sourceWorkspaceId === edge.parentWorkspaceId) { // Pull maps a parent source to a child target - one batched upsert. await upsertEdgeMappings( tx, @@ -407,7 +454,8 @@ export async function applyForkMappingEntries( export async function validateForkMappingTargets( sourceWorkspaceId: string, targetWorkspaceId: string, - entries: ApplyForkMappingEntry[] + entries: ApplyForkMappingEntry[], + executor: DbOrTx = db ): Promise { const withTarget = entries.filter((entry) => entry.targetId != null) if (withTarget.length === 0) return @@ -440,15 +488,17 @@ export async function validateForkMappingTargets( ) const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([ - filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind), - hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set()), + filterExistingForkTargets(executor, targetWorkspaceId, targetIdsByKind), + hasEnvVar + ? getWorkspaceEnvKeys(executor, targetWorkspaceId) + : Promise.resolve(new Set()), getCredentialProvidersByIds( - db, + executor, sourceWorkspaceId, credentialEntries.map((entry) => entry.sourceId) ), getCredentialProvidersByIds( - db, + executor, targetWorkspaceId, credentialEntries.map((entry) => entry.targetId as string) ), diff --git a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts index b6a72af4d98..68a7dd5b48d 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/mapping-store.ts @@ -7,7 +7,7 @@ import type { DbOrTx } from '@/lib/db/types' import type { ForkReferenceResolver, ForkRemapKind, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' /** Mapping rows per insert; each row binds ~8 params, keeping well under PG's limit. */ const MAPPING_INSERT_CHUNK = 1000 @@ -61,6 +61,7 @@ const RESOURCE_TYPE_TO_FORK_KIND: Record custom_block: 'custom-block', custom_tool: 'custom-tool', skill: 'skill', + sandbox: 'sandbox', } /** The remapper kind a stored resource type participates in, or null when it does not remap. */ @@ -82,6 +83,7 @@ const NON_CREDENTIAL_FORK_KIND_TO_RESOURCE_TYPE = { 'custom-tool': 'custom_tool', 'custom-block': 'custom_block', skill: 'skill', + sandbox: 'sandbox', } as const satisfies Record< Exclude, Exclude diff --git a/apps/sim/ee/workspace-forking/lib/mapping/resources.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/resources.test.ts index 3235ed70705..4b869b6d216 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/resources.test.ts @@ -14,7 +14,7 @@ import { listForkCopyableSourceResources, listForkResourceCandidates, loadForkCopyableResourceLabels, -} from '@/ee/workspace-forking/lib/mapping/resources' +} from '@/lib/workflows/references/resources' const executor = dbChainMock.db as unknown as DbOrTx diff --git a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts index 12624c717a2..948f3aee448 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts @@ -24,7 +24,7 @@ const { mockFilterExisting, mockLoadCopyableLabels } = vi.hoisted(() => ({ mockFilterExisting: vi.fn(), mockLoadCopyableLabels: vi.fn(), })) -vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({ +vi.mock('@/lib/workflows/references/resources', () => ({ filterExistingForkTargets: mockFilterExisting, loadForkCopyableResourceLabels: mockLoadCopyableLabels, getWorkspaceEnvKeys: vi.fn(), @@ -36,6 +36,7 @@ vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({ })) import type { DbOrTx } from '@/lib/db/types' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' import { getBlock } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { @@ -49,7 +50,6 @@ import { deriveForkBlockId, EMPTY_FORK_BLOCK_MAP, } from '@/ee/workspace-forking/lib/remap/block-identity' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => diff --git a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts index d777af432a0..ac82d2ff698 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts @@ -11,31 +11,31 @@ import { coerceObjectArray, type SubBlockRecord, } from '@/lib/workflows/persistence/remap-internal-ids' +import { collectForkDependentReconfigs } from '@/lib/workflows/references/dependent-reconfigs' +import { + createCanonicalModeGates, + type ForkReference, + type ForkReferenceResolver, + type ForkRemapKind, + REQUIRED_KINDS, + remapForkBlockType, + remapForkSubBlocks, +} from '@/lib/workflows/references/remap-references' +import { + filterExistingForkTargets, + loadForkCopyableResourceLabels, +} from '@/lib/workflows/references/resources' import { buildSubBlockValues, type CanonicalModeOverrides, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' -import { collectForkDependentReconfigs } from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs' -import { - filterExistingForkTargets, - loadForkCopyableResourceLabels, -} from '@/ee/workspace-forking/lib/mapping/resources' import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan' import { selectForkSyncBlockingRefs, toForkSyncBlockers, } from '@/ee/workspace-forking/lib/promote/sync-blockers' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import { - createCanonicalModeGates, - type ForkReference, - type ForkReferenceResolver, - type ForkRemapKind, - REQUIRED_KINDS, - remapForkBlockType, - remapForkSubBlocks, -} from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' /** diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts index 64283fb9e0a..589de378c58 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts @@ -36,6 +36,7 @@ vi.mock('@/ee/workspace-forking/lib/copy/copy-files', () => ({ executeForkFileBlobCopies: vi.fn(), })) +import type { ForkRemapKind } from '@/lib/workflows/references/remap-references' import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' import { augmentForkResolver, @@ -45,7 +46,6 @@ import { hasPromoteCopySelection, } from '@/ee/workspace-forking/lib/promote/copy-unmapped' import { isForkCopyableKind } from '@/ee/workspace-forking/lib/promote/promote-plan' -import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' const candidates: ForkCopyableUnmapped[] = [ { @@ -235,7 +235,7 @@ describe('augmentForkResolver', () => { describe('copyPromoteUnmappedResources - files + folder content-refs', () => { const tx = {} as DbOrTx // Only edge.childWorkspaceId is read by the copy path. - const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge + const edge: ForkEdge = { childWorkspaceId: 'edge-child', parentWorkspaceId: 'src-ws' } // The promote-built persisted-pair resolver; the copy must forward it verbatim so copied // tables' workflow-group outputs land on the same block ids the workflow writes assign. const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}` diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts index 19269023429..67d11829f5e 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts @@ -4,6 +4,10 @@ import type { PromoteCopyResources, } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' +import type { + ForkReferenceResolver, + ForkRemapKind, +} from '@/lib/workflows/references/remap-references' import { type SerializableForkContentRefMaps, serializeContentRefMaps, @@ -21,10 +25,6 @@ import { resourceTypeToForkKind, } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import type { - ForkReferenceResolver, - ForkRemapKind, -} from '@/ee/workspace-forking/lib/remap/remap-references' /** * The source ids selected for copy at promote, validated against the plan's copyable @@ -235,7 +235,7 @@ export async function copyPromoteUnmappedResources(params: { resolveBlockId, documentMappingContext: { edgeChildWorkspaceId: edge.childWorkspaceId, - sourceIsParent: direction === 'pull', + sourceIsParent: sourceWorkspaceId === edge.parentWorkspaceId, }, }) @@ -285,7 +285,7 @@ export async function copyPromoteUnmappedResources(params: { executor: tx, edgeChildWorkspaceId: edge.childWorkspaceId, userId, - sourceIsParent: direction === 'pull', + sourceIsParent: sourceWorkspaceId === edge.parentWorkspaceId, entries: [...result.mappingEntries, ...fileMappingEntries, ...mappedKbDocs.mappingEntries], }) diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts index af746c3c9ca..efe62ff6b40 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.test.ts @@ -2,11 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import type { ForkReference } from '@/lib/workflows/references/remap-references' import type { ForkCopyableLabel, ForkCopyableSourceResource, -} from '@/ee/workspace-forking/lib/mapping/resources' +} from '@/lib/workflows/references/resources' +import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge' import { assembleForkCopyableUnmapped, buildForkPromotePlanItems, @@ -15,7 +16,6 @@ import { collectForkCopyableIdsByKind, collectForkUnreferencedCopyables, } from '@/ee/workspace-forking/lib/promote/promote-plan' -import type { ForkReference } from '@/ee/workspace-forking/lib/remap/remap-references' const ref = (kind: ForkReference['kind'], sourceId: string): ForkReference => ({ kind, diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts index c666c345c30..b2237c1057f 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote-plan.ts @@ -3,14 +3,13 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type ForkCopyableKind, forkCopyableKindSchema } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' -import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge' -import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' -import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade' +import { toScannerBlocks } from '@/lib/workflows/references/reference-scan' import { - buildForkResolver, - getEdgeMappingRows, - resourceTypeToForkKind, -} from '@/ee/workspace-forking/lib/mapping/mapping-store' + type ForkReference, + type ForkReferenceResolver, + type ForkRemapKind, + scanWorkflowReferences, +} from '@/lib/workflows/references/remap-references' import { type ForkCopyableLabel, type ForkCopyableSourceResource, @@ -18,14 +17,16 @@ import { getWorkspaceEnvKeys, listForkCopyableSourceResources, loadForkCopyableResourceLabels, -} from '@/ee/workspace-forking/lib/mapping/resources' -import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan' +} from '@/lib/workflows/references/resources' +import type { DeployedWorkflowSummary } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage' +import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade' import { - type ForkReference, - type ForkReferenceResolver, - type ForkRemapKind, - scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' + buildForkResolver, + type ForkMappingRow, + getEdgeMappingRows, + resourceTypeToForkKind, +} from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { WorkflowState } from '@/stores/workflows/workflow/types' export interface ForkPromotePlanItem { @@ -352,6 +353,7 @@ export function collectForkUnreferencedCopyables( * Shared by the diff preview and the promote orchestrator. */ export async function computeForkPromotePlan(params: { + mappingRows?: ForkMappingRow[] executor: DbOrTx edge: ForkEdge sourceWorkspaceId: string @@ -375,7 +377,8 @@ export async function computeForkPromotePlan(params: { sourceStates, } = params - const mappingRows = await getEdgeMappingRows(executor, edge.childWorkspaceId) + const mappingRows = + params.mappingRows ?? (await getEdgeMappingRows(executor, edge.childWorkspaceId)) const [targetEnvKeys, sourceEnvKeys] = await Promise.all([ getWorkspaceEnvKeys(executor, targetWorkspaceId), getWorkspaceEnvKeys(executor, sourceWorkspaceId), diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts index 6ab31d8cb80..b6c82080b03 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.test.ts @@ -142,13 +142,13 @@ vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({ upsertPromoteRun: mockUpsertPromoteRun, })) -vi.mock('@/ee/workspace-forking/lib/mapping/resources', () => ({ +vi.mock('@/lib/workflows/references/resources', () => ({ getMcpServerMetaByIds: mockGetMcpServerMeta, })) vi.mock('@/ee/workspace-forking/lib/remap/block-identity', () => ({ buildForkBlockIdResolver: mockBuildBlockIdResolver, })) -vi.mock('@/ee/workspace-forking/lib/remap/remap-references', () => ({ +vi.mock('@/lib/workflows/references/remap-references', () => ({ createForkSubBlockTransform: mockCreateTransform, })) vi.mock('@/ee/workspace-forking/lib/socket', () => ({ @@ -861,6 +861,40 @@ describe('promoteFork trigger URLs', () => { const writeParams = vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0] expect(writeParams.triggerPathByBlockId?.size).toBe(0) }) + + it('rejects an invalid source-scoped choice before writing the workflow or scheduling deployment', async () => { + arrangeReCreatedTrigger() + await expect( + promoteFork({ + ...promoteParams(), + triggerMappings: [ + { + sourceWorkflowId: 'wf-src', + sourceBlockId: 'blk-new', + adoptPath: 'someone-elses-path', + }, + ], + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(copyWorkflowStateIntoTarget).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + expect(performFullDeploy).not.toHaveBeenCalled() + }) + + it('applies an explicit source-scoped choice through the same locked trigger plan', async () => { + arrangeReCreatedTrigger() + const result = await promoteFork({ + ...promoteParams(), + triggerMappings: [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk-new', adoptPath: 'live-slack-path' }, + ], + }) + expect(result.blocked).toBeNull() + expect( + vi.mocked(copyWorkflowStateIntoTarget).mock.calls[0][0].triggerPathByBlockId?.get('blk-new') + ).toBe('live-slack-path') + expect(result.triggerUrlChanges).toEqual([]) + }) }) describe('promoteFork activity', () => { diff --git a/apps/sim/ee/workspace-forking/lib/promote/promote.ts b/apps/sim/ee/workspace-forking/lib/promote/promote.ts index 1087e8be65a..8dc68bf4cfb 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/promote.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/promote.ts @@ -10,6 +10,9 @@ import type { PromoteCopyResources, } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { isFolderPathEffectivelyLocked } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { notifyMcpToolServers } from '@/lib/mcp/workflow-mcp-sync' import { enqueueWorkflowUndeploySideEffects, @@ -17,7 +20,30 @@ import { } from '@/lib/workflows/deployment-outbox' import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' import { undeployWorkflow } from '@/lib/workflows/persistence/utils' +import { collectForkCustomBlockReconfigs } from '@/lib/workflows/references/custom-block-reconfigs' +import { collectForkDependentReconfigs } from '@/lib/workflows/references/dependent-reconfigs' +import { + createForkSubBlockTransform, + type ForkReference, + type ForkReferenceResolver, + type ForkRemapKind, +} from '@/lib/workflows/references/remap-references' +import { getMcpServerMetaByIds } from '@/lib/workflows/references/resources' +import { + findWorkspaceOperationReceipt, + lockWorkspaceOperationRequest, + WorkspaceOperationConflict, + type WorkspaceOperationReport, +} from '@/lib/workspaces/operations/receipts' import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' +import { admitForkSync } from '@/ee/workspace-forking/application/admit-sync' +import { + assertForkPreviewFresh, + assertForkSourceVersions, + type ForkMutationAdmission, + lockForkRevision, +} from '@/ee/workspace-forking/application/revision' +import { validateForkWorkflowBindings } from '@/ee/workspace-forking/application/validate-bindings' import { recordBackgroundWork, startBackgroundWork, @@ -65,12 +91,18 @@ import { reconcileForkDependentValues, translateForkDependentValues, } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' +import { + type ApplyForkMappingEntry, + applyForkMappingEntries, + overlayForkMappingEntries, + validateForkMappingTargets, +} from '@/ee/workspace-forking/lib/mapping/mapping-service' import { deleteWorkflowIdentityByIds, type ForkMappingUpsert, + getEdgeMappingRows, upsertEdgeMappings, } from '@/ee/workspace-forking/lib/mapping/mapping-store' -import { getMcpServerMetaByIds } from '@/ee/workspace-forking/lib/mapping/resources' import { collectForkSyncBlockers, verifyForkDropAcknowledgments, @@ -97,12 +129,6 @@ import { } from '@/ee/workspace-forking/lib/promote/trigger-urls' import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import { createForkBlockTypeTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' -import { - createForkSubBlockTransform, - type ForkReference, - type ForkReferenceResolver, - type ForkRemapKind, -} from '@/ee/workspace-forking/lib/remap/remap-references' import { notifyForkWorkflowChanged } from '@/ee/workspace-forking/lib/socket' const logger = createLogger('WorkspaceForkPromote') @@ -147,13 +173,24 @@ export interface PromoteForkParams { dropReferences?: Array<{ kind: ForkRemapKind; sourceId: string }> /** * Which retiring public URL each arriving trigger takes over. Re-validated in-transaction - * against the adoptable set the plan derives, so an entry the plan does not offer is ignored. + * Source workflow/block choices are validated against the live adoptable set. Legacy + * block-only callers retain their existing behavior of ignoring unsupported choices. */ triggerMappings?: ForkTriggerMappingInput[] requestId?: string + admission?: ForkMutationAdmission + mappings?: ApplyForkMappingEntry[] + sourceDependentValues?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + value: string + }> } export interface PromoteForkResult { + operation?: WorkspaceOperationReport + replayed?: boolean promoteRunId: string updated: number created: number @@ -387,6 +424,7 @@ type PromoteTxBlocked = | { blocked: 'cleared-refs'; blockers: ForkSyncBlocker[] } interface PromoteTxApplied { + operation?: WorkspaceOperationReport blocked: null promoteRunId: string deployTargetIds: string[] @@ -466,6 +504,18 @@ function groupDependentOverrides( export async function promoteFork(params: PromoteForkParams): Promise { const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params const requestId = params.requestId ?? 'unknown' + const admission = params.admission + const revisionScope = { sourceWorkspaceId, targetWorkspaceId, edge } + if (admission) { + const receipt = await findWorkspaceOperationReceipt( + db, + admission.workspaceId, + admission.requestId, + admission.requestHash + ) + if (receipt?.syncResult) return { ...receipt.syncResult, operation: receipt, replayed: true } + await assertForkPreviewFresh(db, revisionScope, admission) + } // Distinguish an OMITTED dependent mapping (leave the store as-is) from an explicit empty // array (clear it). Provided values are normalized to the store row shape here - BEFORE the @@ -474,15 +524,17 @@ export async function promoteFork(params: PromoteForkParams): Promise ({ - targetWorkflowId: entry.workflowId, - targetBlockId: entry.blockId, - subBlockKey: entry.subBlockKey, - value: entry.value, - })) - : null + const dependentValuesProvided = + params.dependentValues !== undefined || params.sourceDependentValues !== undefined + const providedDependentValues: ForkDependentValue[] | null = + params.dependentValues !== undefined + ? (params.dependentValues ?? []).map((entry) => ({ + targetWorkflowId: entry.workflowId, + targetBlockId: entry.blockId, + subBlockKey: entry.subBlockKey, + value: entry.value, + })) + : null // UX-only preflight against the actual target workspace payer. Authoritative per-blob // admission + increments happen later with metadata activation in short transactions. @@ -507,558 +559,756 @@ export async function promoteFork(params: PromoteForkParams): Promise { - // Bound lock waits so a contended sync into this target fails fast instead of - // stagnating the pool. Must run before acquiring the advisory locks below. - await setForkLockTimeout(tx) - // Target lock before edge lock (consistent ordering): the target lock serializes - // every sync into this target so sibling forks can't interleave writes, and so - // rollback's "newest sync" check stays race-free against a concurrent promote. - await acquireForkTargetLock(tx, targetWorkspaceId) - await acquireForkEdgeLock(tx, edge.childWorkspaceId) + const { deployedWorkflows, sourceStates, sourceVersionIds } = + await loadSourceDeployedStates(sourceWorkspaceId) - const plan = await computeForkPromotePlan({ - executor: tx, + const sourceItems = deployedWorkflows.map((item) => ({ + sourceWorkflowId: item.id, + targetWorkflowId: item.id, + mode: 'create' as const, + })) + const sourceFields = params.sourceDependentValues + ? collectForkDependentReconfigs( + sourceItems, + sourceStates, + (_workflowId, blockId) => blockId, + 'create' + ) + : [] + if (params.sourceDependentValues) { + const previewPlan = await computeForkPromotePlan({ + executor: db, edge, sourceWorkspaceId, targetWorkspaceId, direction, deployedSourceWorkflows: deployedWorkflows, sourceStates, + mappingRows: overlayForkMappingEntries( + await getEdgeMappingRows(db, edge.childWorkspaceId), + edge, + sourceWorkspaceId, + params.mappings ?? [] + ), }) + sourceFields.push( + ...(await collectForkCustomBlockReconfigs({ + items: sourceItems, + sourceStates, + resolveTargetBlockId: (_workflowId, blockId) => blockId, + resolve: previewPlan.resolver, + targetWorkspaceId, + })) + ) + } + const txResult: PromoteTxBlocked | PromoteTxApplied | { receipt: WorkspaceOperationReport } = + await db.transaction(async (tx) => { + // Bound lock waits so a contended sync into this target fails fast instead of + // stagnating the pool. Must run before acquiring the advisory locks below. + await setForkLockTimeout(tx) + if (admission) { + await lockWorkspaceOperationRequest(tx, admission.workspaceId, admission.requestId) + const receipt = await findWorkspaceOperationReceipt( + tx, + admission.workspaceId, + admission.requestId, + admission.requestHash + ) + if (receipt?.syncResult) return { receipt } + } + // Target lock before edge lock (consistent ordering): the target lock serializes + // every sync into this target so sibling forks can't interleave writes, and so + // rollback's "newest sync" check stays race-free against a concurrent promote. + await acquireForkTargetLock(tx, targetWorkspaceId) + await acquireForkEdgeLock(tx, edge.childWorkspaceId) + if (admission) { + await lockForkRevision(tx, revisionScope) + await assertForkPreviewFresh(tx, revisionScope, admission) + await assertForkSourceVersions(tx, sourceWorkspaceId, sourceVersionIds) + } + if (params.mappings) + await validateForkMappingTargets(sourceWorkspaceId, targetWorkspaceId, params.mappings, tx) - if (plan.excludedTargets.length > 0) { - logger.info( - `[${requestId}] Promote leaving ${plan.excludedTargets.length} sync-excluded target workflow(s) untouched`, - { - sourceWorkspaceId, - targetWorkspaceId, - excludedTargets: plan.excludedTargets.map((target) => target.name), - } - ) - } + const plan = await computeForkPromotePlan({ + executor: tx, + edge, + sourceWorkspaceId, + targetWorkspaceId, + direction, + deployedSourceWorkflows: deployedWorkflows, + sourceStates, + ...(params.mappings + ? { + mappingRows: overlayForkMappingEntries( + await getEdgeMappingRows(tx, edge.childWorkspaceId), + edge, + sourceWorkspaceId, + params.mappings + ), + } + : {}), + }) - const now = new Date() + if (admission) { + await validateForkWorkflowBindings({ + executor: tx, + workspaceId: targetWorkspaceId, + sourceStates, + items: plan.items, + resolve: plan.resolver, + lock: true, + }) + await assertForkPreviewFresh(tx, revisionScope, admission) + const targets = plan.items + .filter((item) => item.mode === 'replace') + .map((item) => item.targetWorkflowId) + if (targets.length) { + const folderIndex = await loadActiveFolderPathIndex(targetWorkspaceId, 'workflow', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const targetsWithLocks = await tx + .select({ id: workflow.id, folderId: workflow.folderId, locked: workflow.locked }) + .from(workflow) + .where(inArray(workflow.id, targets)) + const locked = targetsWithLocks.find( + (item) => + item.locked || + (item.folderId && isFolderPathEffectivelyLocked(folderIndex, item.folderId)) + ) + if (locked) + throw new WorkspaceOperationConflict('A sync target or its folder is locked', { + applied: false, + reason: 'target_locked', + workflowId: locked.id, + }) + } + } - // Copy the selected unmapped resources (referenced and unreferenced) into the target BEFORE - // the gate, so a user can copy rather than map each one. The gate is evaluated against the - // post-copy state (the copy resolves the selected refs), so the copy only runs when the sync - // will actually proceed - if required refs remain unmapped, we block without copying anything. - const { selection: copySelection, willResolve } = buildPromoteCopySelection( - params.copyResources, - plan.copyableUnmapped - ) - // Drop acknowledgments the server will honour, verified against the SOURCE inside this same - // locked tx. Resolved BEFORE the unmapped gate because a source-deleted reference on a - // required field is in `unmappedRequired`: gating on it first would reject the sync with - // "map all required ... first" no matter what the user dropped, making Drop inert for the - // required references it exists to unblock. - const verifiedDrops = await verifyForkDropAcknowledgments( - tx, - sourceWorkspaceId, - params.dropReferences - ) - const droppedKeys = new Set(verifiedDrops.map((entry) => `${entry.kind}:${entry.sourceId}`)) - // plan.unmappedRequired is already references.filter(resolver == null).filter(required), so - // subtracting the refs the copy will resolve is equivalent to re-scanning the predicate. - const postCopyUnmappedRequired = plan.unmappedRequired.filter( - (reference) => - !willResolve.has(`${reference.kind}:${reference.sourceId}`) && - !droppedKeys.has(`${reference.kind}:${reference.sourceId}`) - ) - if (postCopyUnmappedRequired.length > 0) { - return { - blocked: 'unmapped', - unmappedRequired: postCopyUnmappedRequired.map((reference) => ({ - kind: reference.kind, - sourceId: reference.sourceId, - required: reference.required, - blockName: reference.blockName, - })), + if (plan.excludedTargets.length > 0) { + logger.info( + `[${requestId}] Promote leaving ${plan.excludedTargets.length} sync-excluded target workflow(s) untouched`, + { + sourceWorkspaceId, + targetWorkspaceId, + excludedTargets: plan.excludedTargets.map((target) => target.name), + } + ) } - } - // Resolve each source block to its counterpart's EXISTING id (via the persisted block - // map) instead of re-deriving, so a push keeps the parent's original block ids - and the - // webhook URLs derived from them - stable. Falls back to derive for blocks with no pair - // yet (added since the last sync). Loaded here (read-only) so the would-clear gate below - // and the write loop share one block map. - const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId - const blockMap = await loadForkBlockMap(tx, edge.childWorkspaceId) - const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) + const now = new Date() - // Zero-cleared-refs gate: the sync proceeds only when NO reference would clear in any - // synced target workflow (source fully operational -> target fully operational). Evaluated - // against the plan resolver overlaid with the validated copy selection (a selected copy - // resolves its references), BEFORE any write. Authoritative versus the diff's unlocked - // preview - state drift between preview and Sync re-blocks here (TOCTOU) - and it makes the - // in-tx remap's clear-unresolved behavior an unreachable defense-in-depth backstop. The - // plan's unmapped references are threaded through so the gate's happy path reuses the plan's - // scan (computed moments earlier over the same states, inside this same locked tx) instead of - // re-running the full per-block reference scan; the scan re-runs only when something blocks. - let droppedReferences: Array<{ kind: ForkRemapKind; sourceId: string }> = [] - const gateResolver: ForkReferenceResolver = (kind, sourceId) => - willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId) - const { blockers, appliedDrops } = await collectForkSyncBlockers({ - executor: tx, - sourceWorkspaceId, - items: plan.items, - sourceStates, - resolver: gateResolver, - workflowIdMap: plan.workflowIdMap, - resolveBlockId, - planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional], - droppedReferences: verifiedDrops, - }) - if (blockers.length > 0) { - return { blocked: 'cleared-refs', blockers } - } - droppedReferences = appliedDrops + // Copy the selected unmapped resources (referenced and unreferenced) into the target BEFORE + // the gate, so a user can copy rather than map each one. The gate is evaluated against the + // post-copy state (the copy resolves the selected refs), so the copy only runs when the sync + // will actually proceed - if required refs remain unmapped, we block without copying anything. + const { selection: copySelection, willResolve } = buildPromoteCopySelection( + params.copyResources, + plan.copyableUnmapped + ) + // Drop acknowledgments the server will honour, verified against the SOURCE inside this same + // locked tx. Resolved BEFORE the unmapped gate because a source-deleted reference on a + // required field is in `unmappedRequired`: gating on it first would reject the sync with + // "map all required ... first" no matter what the user dropped, making Drop inert for the + // required references it exists to unblock. + const verifiedDrops = await verifyForkDropAcknowledgments( + tx, + sourceWorkspaceId, + params.dropReferences + ) + const droppedKeys = new Set(verifiedDrops.map((entry) => `${entry.kind}:${entry.sourceId}`)) + // plan.unmappedRequired is already references.filter(resolver == null).filter(required), so + // subtracting the refs the copy will resolve is equivalent to re-scanning the predicate. + const postCopyUnmappedRequired = plan.unmappedRequired.filter( + (reference) => + !willResolve.has(`${reference.kind}:${reference.sourceId}`) && + !droppedKeys.has(`${reference.kind}:${reference.sourceId}`) + ) + if (postCopyUnmappedRequired.length > 0) { + if (admission) + throw new WorkspaceOperationConflict('Sync requires resource mappings', { + applied: false, + reason: 'unresolved_bindings', + unresolvedBindings: postCopyUnmappedRequired, + }) + return { + blocked: 'unmapped', + unmappedRequired: postCopyUnmappedRequired.map((reference) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + required: reference.required, + blockName: reference.blockName, + })), + } + } - // Resolve the source->target folder map BEFORE the copy so the folders already exist in the - // target and the copy can rewrite `sim:folder/` references inside copied skill / markdown - // bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target - // folders that already match by name within the same mapped parent. Creation is scoped to - // the folders that will hold a synced workflow (plus ancestors) - a folder whose subtree - // syncs nothing is never created empty in the target, though it still maps onto a matching - // existing target folder so prior syncs' refs keep resolving. - const { folderIdMap } = await resolveForkFolderMapping({ - tx, - sourceWorkspaceId, - targetWorkspaceId, - userId, - now, - resourceType: 'workflow', - contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId), - }) + // Resolve each source block to its counterpart's EXISTING id (via the persisted block + // map) instead of re-deriving, so a push keeps the parent's original block ids - and the + // webhook URLs derived from them - stable. Falls back to derive for blocks with no pair + // yet (added since the last sync). Loaded here (read-only) so the would-clear gate below + // and the write loop share one block map. + const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId + const blockMap = await loadForkBlockMap(tx, edge.childWorkspaceId) + const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) - let resolver = plan.resolver - let copyContentPlan: ForkContentPlan | null = null - let copyContentRefMaps: SerializableForkContentRefMaps | null = null - let copyContentBlobTasks: BlobCopyTask[] = [] - // Every dependent value this sync will apply, as flat store rows: the provided payload, or - // (omitted) the persisted store for the plan's targets - loaded here, BEFORE the copy, so - // document picks can join the copy's discovery set below. The apply map + reconcile further - // down consume these after translating them through the post-copy resolver. - const flatDependentValues = - providedDependentValues ?? - (await loadForkDependentValues( - tx, - edge.childWorkspaceId, - plan.items.map((item) => item.targetWorkflowId) - )) + // Zero-cleared-refs gate: the sync proceeds only when NO reference would clear in any + // synced target workflow (source fully operational -> target fully operational). Evaluated + // against the plan resolver overlaid with the validated copy selection (a selected copy + // resolves its references), BEFORE any write. Authoritative versus the diff's unlocked + // preview - state drift between preview and Sync re-blocks here (TOCTOU) - and it makes the + // in-tx remap's clear-unresolved behavior an unreachable defense-in-depth backstop. The + // plan's unmapped references are threaded through so the gate's happy path reuses the plan's + // scan (computed moments earlier over the same states, inside this same locked tx) instead of + // re-running the full per-block reference scan; the scan re-runs only when something blocks. + let droppedReferences: Array<{ kind: ForkRemapKind; sourceId: string }> = [] + const gateResolver: ForkReferenceResolver = (kind, sourceId) => + willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId) + const { blockers, appliedDrops } = await collectForkSyncBlockers({ + executor: tx, + sourceWorkspaceId, + items: plan.items, + sourceStates, + resolver: gateResolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional], + droppedReferences: verifiedDrops, + }) + if (blockers.length > 0) { + if (admission) + throw new WorkspaceOperationConflict('Sync would clear unresolved references', { + applied: false, + reason: 'unresolved_bindings', + blockers, + }) + return { blocked: 'cleared-refs', blockers } + } + droppedReferences = appliedDrops + if (params.mappings) + await applyForkMappingEntries(tx, edge, userId, sourceWorkspaceId, params.mappings) + const sourceDependentValues = params.sourceDependentValues?.map((entry) => { + const item = plan.items.find((item) => item.sourceWorkflowId === entry.sourceWorkflowId) + if ( + !item || + !sourceFields.some( + (field) => + field.targetWorkflowId === entry.sourceWorkflowId && + field.targetBlockId === entry.sourceBlockId && + field.subBlockKey === entry.subBlockKey + ) + ) { + throw new WorkspaceOperationConflict( + 'Dependent override does not address a configurable source field', + { applied: false, reason: 'invalid_binding', ...entry, value: undefined } + ) + } + return { + targetWorkflowId: item.targetWorkflowId, + targetBlockId: resolveBlockId(item.targetWorkflowId, entry.sourceBlockId), + subBlockKey: entry.subBlockKey, + value: entry.value, + } + }) - // Knowledge-document ids the synced workflows reference, from the plan's already-scanned - // references (never a re-scan inside this locked tx) - UNIONED with the dependent-value - // picks: a document re-picked in the sync page's reconfigure selector under a copy-resolved - // KB isn't referenced by the source STATE, but must still be copied so the applied pick - // resolves in the target. Non-document values ride along harmlessly: every consumer filters - // candidates through `inArray(document.id, ...)`, so a label or column id matches no row. - const referencedDocumentIds = [ - ...new Set([ - ...plan.references - .filter((reference) => reference.kind === 'knowledge-document') - .map((reference) => reference.sourceId), - ...flatDependentValues.map((entry) => entry.value).filter((value) => value !== ''), - ]), - ] - // Run the copy when the user selected resources to copy OR any document is referenced (a - // referenced document under an already-mapped KB is auto-copied into that KB so its reference - // remaps instead of clearing). It runs only after the required-reference gate above, so a - // blocked sync copies nothing. - let copyIdMapByKind: Map> | null = null - if (hasPromoteCopySelection(copySelection) || referencedDocumentIds.length > 0) { - const copyResult = await copyPromoteUnmappedResources({ + // Resolve the source->target folder map BEFORE the copy so the folders already exist in the + // target and the copy can rewrite `sim:folder/` references inside copied skill / markdown + // bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target + // folders that already match by name within the same mapped parent. Creation is scoped to + // the folders that will hold a synced workflow (plus ancestors) - a folder whose subtree + // syncs nothing is never created empty in the target, though it still maps onto a matching + // existing target folder so prior syncs' refs keep resolving. + const { folderIdMap } = await resolveForkFolderMapping({ tx, - edge, sourceWorkspaceId, targetWorkspaceId, - direction, userId, now, - selection: copySelection, - workflowIdMap: plan.workflowIdMap, - folderIdMap, - resolver: plan.resolver, - // The block map loaded above backs this resolver; copied tables' workflow-group - // outputs must land on the same target block ids the workflow writes below assign. - resolveBlockId, - referencedDocumentIds, + resourceType: 'workflow', + contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId), }) - resolver = augmentForkResolver(plan.resolver, copyResult.copyIdMapByKind) - copyIdMapByKind = copyResult.copyIdMapByKind - copyContentPlan = copyResult.contentPlan - copyContentRefMaps = copyResult.contentRefMaps - copyContentBlobTasks = copyResult.blobTasks - } - // Target rows for the MAPPED (or just-copied) MCP servers this sync references, so remapped - // tool-input entries rewrite their embedded `serverUrl`/`serverName` from the target server - // instead of carrying the source's (which would show a false "URL changed" stale badge in - // the target UI). Bounded: the plan's references are deduped per (kind, id), so this is one - // `inArray` read over the distinct referenced servers. Uses the post-copy resolver, so a - // server copied this sync resolves to its fresh row (same name/url - the rewrite is a no-op). - const mappedMcpServerTargetIds = [ - ...new Set( - plan.references - .filter((reference) => reference.kind === 'mcp-server') - .map((reference) => resolver('mcp-server', reference.sourceId)) - .filter((targetId): targetId is string => targetId != null) - ), - ] - const mcpServerMetaById = await getMcpServerMetaByIds( - tx, - targetWorkspaceId, - mappedMcpServerTargetIds - ) + let resolver = plan.resolver + let copyContentPlan: ForkContentPlan | null = null + let copyContentRefMaps: SerializableForkContentRefMaps | null = null + let copyContentBlobTasks: BlobCopyTask[] = [] + // Every dependent value this sync will apply, as flat store rows: the provided payload, or + // (omitted) the persisted store for the plan's targets - loaded here, BEFORE the copy, so + // document picks can join the copy's discovery set below. The apply map + reconcile further + // down consume these after translating them through the post-copy resolver. + const flatDependentValues = + sourceDependentValues ?? + providedDependentValues ?? + (await loadForkDependentValues( + tx, + edge.childWorkspaceId, + plan.items.map((item) => item.targetWorkflowId) + )) - const transform = createForkSubBlockTransform(resolver, { - resolveMcpServerMeta: (targetServerId) => mcpServerMetaById.get(targetServerId), - // Copy provenance: a parent resolved through THIS sync's copy selection keeps its - // copy-faithful dependents (a copied table's column picks) instead of clearing them. - isCopiedTarget: (kind, sourceId) => copyIdMapByKind?.get(kind)?.has(sourceId) ?? false, - }) - // Custom blocks reference by block TYPE, not by a sub-block value, so their rewrite runs - // on its own channel. An unmapped one keeps the source's type — the sync gate has already - // refused the promote by then (`unmapped-custom-block`), so this never silently ships. - const blockTypeTransform = createForkBlockTypeTransform( - (kind, sourceId) => resolver(kind, sourceId) ?? null - ) + // Knowledge-document ids the synced workflows reference, from the plan's already-scanned + // references (never a re-scan inside this locked tx) - UNIONED with the dependent-value + // picks: a document re-picked in the sync page's reconfigure selector under a copy-resolved + // KB isn't referenced by the source STATE, but must still be copied so the applied pick + // resolves in the target. Non-document values ride along harmlessly: every consumer filters + // candidates through `inArray(document.id, ...)`, so a label or column id matches no row. + const referencedDocumentIds = [ + ...new Set([ + ...plan.references + .filter((reference) => reference.kind === 'knowledge-document') + .map((reference) => reference.sourceId), + ...flatDependentValues.map((entry) => entry.value).filter((value) => value !== ''), + ]), + ] + // Run the copy when the user selected resources to copy OR any document is referenced (a + // referenced document under an already-mapped KB is auto-copied into that KB so its reference + // remaps instead of clearing). It runs only after the required-reference gate above, so a + // blocked sync copies nothing. + let copyIdMapByKind: Map> | null = null + if (hasPromoteCopySelection(copySelection) || referencedDocumentIds.length > 0) { + const copyResult = await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId, + targetWorkspaceId, + direction, + userId, + now, + selection: copySelection, + workflowIdMap: plan.workflowIdMap, + folderIdMap, + resolver: plan.resolver, + // The block map loaded above backs this resolver; copied tables' workflow-group + // outputs must land on the same target block ids the workflow writes below assign. + resolveBlockId, + referencedDocumentIds, + }) + resolver = augmentForkResolver(plan.resolver, copyResult.copyIdMapByKind) + copyIdMapByKind = copyResult.copyIdMapByKind + copyContentPlan = copyResult.contentPlan + copyContentRefMaps = copyResult.contentRefMaps + copyContentBlobTasks = copyResult.blobTasks + } + + // Target rows for the MAPPED (or just-copied) MCP servers this sync references, so remapped + // tool-input entries rewrite their embedded `serverUrl`/`serverName` from the target server + // instead of carrying the source's (which would show a false "URL changed" stale badge in + // the target UI). Bounded: the plan's references are deduped per (kind, id), so this is one + // `inArray` read over the distinct referenced servers. Uses the post-copy resolver, so a + // server copied this sync resolves to its fresh row (same name/url - the rewrite is a no-op). + const mappedMcpServerTargetIds = [ + ...new Set( + plan.references + .filter((reference) => reference.kind === 'mcp-server') + .map((reference) => resolver('mcp-server', reference.sourceId)) + .filter((targetId): targetId is string => targetId != null) + ), + ] + const mcpServerMetaById = await getMcpServerMetaByIds( + tx, + targetWorkspaceId, + mappedMcpServerTargetIds + ) - // Batch every prior-version read (replace + archive targets) into one query before any - // write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so - // they still reflect the active version each target had before this sync. - const priorVersionByTarget = await getActiveDeploymentVersionNumbers(tx, [ - ...plan.items.filter((item) => item.mode === 'replace').map((item) => item.targetWorkflowId), - ...plan.archivedTargetIds, - ]) + const transform = createForkSubBlockTransform(resolver, { + resolveMcpServerMeta: (targetServerId) => mcpServerMetaById.get(targetServerId), + // Copy provenance: a parent resolved through THIS sync's copy selection keeps its + // copy-faithful dependents (a copied table's column picks) instead of clearing them. + isCopiedTarget: (kind, sourceId) => copyIdMapByKind?.get(kind)?.has(sourceId) ?? false, + }) + // Custom blocks reference by block TYPE, not by a sub-block value, so their rewrite runs + // on its own channel. An unmapped one keeps the source's type — the sync gate has already + // refused the promote by then (`unmapped-custom-block`), so this never silently ships. + const blockTypeTransform = createForkBlockTypeTransform( + (kind, sourceId) => resolver(kind, sourceId) ?? null + ) - // Preload the target's active workflow names so per-workflow collision checks read from - // memory instead of one query each inside this locked tx. The DB unique index remains - // the correctness backstop (a stale snapshot only risks a rare, retry-able conflict). - const nameRegistry = await loadWorkflowNameRegistry(tx, targetWorkspaceId) + // Batch every prior-version read (replace + archive targets) into one query before any + // write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so + // they still reflect the active version each target had before this sync. + const priorVersionByTarget = await getActiveDeploymentVersionNumbers(tx, [ + ...plan.items + .filter((item) => item.mode === 'replace') + .map((item) => item.targetWorkflowId), + ...plan.archivedTargetIds, + ]) - // Replace targets (the only mode with a prior target state) - reused by the draft preload - // and the dependent-value apply/load below. - const replaceTargetIds = plan.items - .filter((item) => item.mode === 'replace') - .map((item) => item.targetWorkflowId) + // Preload the target's active workflow names so per-workflow collision checks read from + // memory instead of one query each inside this locked tx. The DB unique index remains + // the correctness backstop (a stale snapshot only risks a rare, retry-able conflict). + const nameRegistry = await loadWorkflowNameRegistry(tx, targetWorkspaceId) - // Preload the target's current draft subBlocks (replace targets only) so the copy can - // detect dependent fields a parent change cleared that the stored mapping didn't refill - // (surfaced as needs-configuration). One batched query pre-write, so it reflects the - // pre-sync target state. - const targetDraftByWorkflow = await loadTargetDraftSubBlocks(tx, replaceTargetIds) + // Replace targets (the only mode with a prior target state) - reused by the draft preload + // and the dependent-value apply/load below. + const replaceTargetIds = plan.items + .filter((item) => item.mode === 'replace') + .map((item) => item.targetWorkflowId) - // The dependent-value apply map (target workflow -> block id -> subblock -> value), built - // from the flat values loaded above (the provided payload, or - omitted - the stored - // mapping, which stays the sole source of truth; the reconcile below is skipped then so an - // omitted field never wipes it). Values are translated through the post-copy resolver - // FIRST: the apply runs AFTER the reference remap inside `copyWorkflowStateIntoTarget` and - // wins for its subblock, so a SOURCE document id picked under a copy-resolved KB must - // become the copied counterpart here - otherwise the stale source id would clobber the - // remapped value in the written state. Create targets are included: a value pre-configured - // for a never-synced workflow (keyed by its deterministic target id) applies on the first - // sync that creates it. - const appliedDependentValues = translateForkDependentValues(flatDependentValues, resolver) - const overridesByWorkflow = groupDependentOverrides(appliedDependentValues) + // Preload the target's current draft subBlocks (replace targets only) so the copy can + // detect dependent fields a parent change cleared that the stored mapping didn't refill + // (surfaced as needs-configuration). One batched query pre-write, so it reflects the + // pre-sync target state. + const targetDraftByWorkflow = await loadTargetDraftSubBlocks(tx, replaceTargetIds) - // New block pairs recorded by the write loop (blocks added since the last sync), using the - // block map + resolver loaded before the would-clear gate above. - const blockPairs: ForkBlockPair[] = [] + // The dependent-value apply map (target workflow -> block id -> subblock -> value), built + // from the flat values loaded above (the provided payload, or - omitted - the stored + // mapping, which stays the sole source of truth; the reconcile below is skipped then so an + // omitted field never wipes it). Values are translated through the post-copy resolver + // FIRST: the apply runs AFTER the reference remap inside `copyWorkflowStateIntoTarget` and + // wins for its subblock, so a SOURCE document id picked under a copy-resolved KB must + // become the copied counterpart here - otherwise the stale source id would clobber the + // remapped value in the written state. Create targets are included: a value pre-configured + // for a never-synced workflow (keyed by its deterministic target id) applies on the first + // sync that creates it. + const appliedDependentValues = translateForkDependentValues(flatDependentValues, resolver) + const overridesByWorkflow = groupDependentOverrides(appliedDependentValues) - // Every trigger block's final public path, resolved ONCE for the whole sync: the path a - // target block already serves, or a retiring one it adopts. Rebuilt here rather than trusted - // from the caller, so an adoption is re-validated against the live webhooks inside this same - // locked transaction and a stale preview can never move a URL the plan no longer offers. - const triggerPlan = buildForkTriggerPlan({ - items: plan.items, - sourceStates, - resolveBlockId, - targetWebhooks: await loadTargetWebhookPathsByBlock( - tx, - plan.items.map((item) => item.targetWorkflowId) - ), - }) - const { pathByTargetBlockId: triggerPathByBlockId, changes: triggerUrlChanges } = - resolveForkTriggerPaths(triggerPlan, params.triggerMappings) + // New block pairs recorded by the write loop (blocks added since the last sync), using the + // block map + resolver loaded before the would-clear gate above. + const blockPairs: ForkBlockPair[] = [] - const updatedSnapshots: PromoteRunWorkflowSnapshot[] = [] - const createdTargetIds: string[] = [] - const writtenItems: typeof plan.items = [] - const needsConfiguration: PromoteTxApplied['needsConfiguration'] = [] - const clearedOptional: PromoteTxApplied['clearedOptional'] = [] - for (const item of plan.items) { - // Use the pre-read source state (loaded above, before the tx). An item only - // exists when its state was present at read time, so this lookup hits; the - // guard stays as defense so the written counts below never over-report. - const sourceState = sourceStates.get(item.sourceWorkflowId) - if (!sourceState) continue - if (item.mode === 'replace') { - const priorVersion = priorVersionByTarget.get(item.targetWorkflowId) ?? null - updatedSnapshots.push({ workflowId: item.targetWorkflowId, priorVersion }) - } else { - createdTargetIds.push(item.targetWorkflowId) + // Every trigger block's final public path, resolved ONCE for the whole sync: the path a + // target block already serves, or a retiring one it adopts. Rebuilt here rather than trusted + // from the caller, so an adoption is re-validated against the live webhooks inside this same + // locked transaction and a stale preview can never move a URL the plan no longer offers. + const triggerPlan = buildForkTriggerPlan({ + items: plan.items, + sourceStates, + resolveBlockId, + targetWebhooks: await loadTargetWebhookPathsByBlock( + tx, + plan.items.map((item) => item.targetWorkflowId) + ), + }) + const { pathByTargetBlockId: triggerPathByBlockId, changes: triggerUrlChanges } = + resolveForkTriggerPaths(triggerPlan, params.triggerMappings) + + const updatedSnapshots: PromoteRunWorkflowSnapshot[] = [] + const createdTargetIds: string[] = [] + const writtenItems: typeof plan.items = [] + const needsConfiguration: PromoteTxApplied['needsConfiguration'] = [] + const clearedOptional: PromoteTxApplied['clearedOptional'] = [] + for (const item of plan.items) { + // Use the pre-read source state (loaded above, before the tx). An item only + // exists when its state was present at read time, so this lookup hits; the + // guard stays as defense so the written counts below never over-report. + const sourceState = sourceStates.get(item.sourceWorkflowId) + if (!sourceState) continue + if (item.mode === 'replace') { + const priorVersion = priorVersionByTarget.get(item.targetWorkflowId) ?? null + updatedSnapshots.push({ workflowId: item.targetWorkflowId, priorVersion }) + } else { + createdTargetIds.push(item.targetWorkflowId) + } + const copyResult = await copyWorkflowStateIntoTarget({ + tx, + targetWorkflowId: item.targetWorkflowId, + targetWorkspaceId, + userId, + mode: item.mode, + now, + sourceState, + sourceMeta: item.sourceMeta, + workflowIdMap: plan.workflowIdMap, + folderIdMap, + transformSubBlocks: transform, + transformBlockType: blockTypeTransform, + targetCurrentBlocks: + item.mode === 'replace' ? targetDraftByWorkflow.get(item.targetWorkflowId) : undefined, + dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId), + nameRegistry, + resolveBlockId, + triggerPathByBlockId, + requestId, + }) + blockPairs.push( + ...toForkBlockPairs( + copyResult.blockIdMapping, + sourceIsParent, + item.sourceWorkflowId, + item.targetWorkflowId + ) + ) + const requiredCleared = copyResult.clearedDependents.filter((field) => field.required) + const optionalCleared = copyResult.clearedDependents.filter((field) => !field.required) + if (requiredCleared.length > 0) { + needsConfiguration.push({ + workflowId: item.targetWorkflowId, + workflowName: item.sourceMeta.name, + // Surface the block names (deduped) - the field titles ("Label") aren't useful. + blocks: [...new Set(requiredCleared.map((field) => field.blockName))], + }) + } + if (optionalCleared.length > 0) { + clearedOptional.push({ + workflowName: item.sourceMeta.name, + blocks: [...new Set(optionalCleared.map((field) => field.blockName))], + }) + } + writtenItems.push(item) } - const copyResult = await copyWorkflowStateIntoTarget({ + + // Reconcile block-identity pairs for the written source workflows: clears pairs for + // blocks the source dropped (e.g. a deleted trigger) and any stale pair from a re-created + // target, then records the live ones - so the next promote resolves these blocks to these + // same ids and never re-homes one onto an archived workflow's block. + await reconcileForkBlockPairs( tx, - targetWorkflowId: item.targetWorkflowId, - targetWorkspaceId, + edge.childWorkspaceId, + sourceIsParent, + writtenItems.map((item) => item.sourceWorkflowId), + blockPairs + ) + + // Carry chat deployments for written targets that have NO chat row yet (typically + // create-mode targets): a fresh `{target-workspace}-{workflow}-{randomnum}` identifier with + // the source's config, live once this sync's deploy lands. Targets with any existing chat + // (live or archived) are left untouched - an earlier carry-over keeps its URL on every + // subsequent sync, and a deliberately archived chat is never resurrected. Targets whose + // redeploy this sync SKIPS (required dependents cleared) are excluded: their chat would + // squat a live identifier while nothing can serve - the next successful sync carries it. + const needsConfigurationTargetIds = new Set( + needsConfiguration.map((entry) => entry.workflowId) + ) + await copyForkChatDeployments({ + tx, + pairs: writtenItems.flatMap((item) => + needsConfigurationTargetIds.has(item.targetWorkflowId) + ? [] + : [ + { + sourceWorkflowId: item.sourceWorkflowId, + targetWorkflowId: item.targetWorkflowId, + workflowName: item.sourceMeta.name, + }, + ] + ), + targetWorkspaceName, userId, - mode: item.mode, now, - sourceState, - sourceMeta: item.sourceMeta, - workflowIdMap: plan.workflowIdMap, - folderIdMap, - transformSubBlocks: transform, - transformBlockType: blockTypeTransform, - targetCurrentBlocks: - item.mode === 'replace' ? targetDraftByWorkflow.get(item.targetWorkflowId) : undefined, - dependentOverrides: overridesByWorkflow.get(item.targetWorkflowId), - nameRegistry, resolveBlockId, - triggerPathByBlockId, requestId, }) - blockPairs.push( - ...toForkBlockPairs( - copyResult.blockIdMapping, - sourceIsParent, - item.sourceWorkflowId, - item.targetWorkflowId + + // Mirror workflow-as-MCP-tool attachments onto MAPPED workflow-publishing servers for the + // written pairs: missing target attachments are created, drifted metadata refreshed, and a + // detached source's counterpart archived. The deployment outbox re-derives each affected + // tool's parameter schema when the target deploys below. + const mcpAttachmentResult = await reconcileForkWorkflowMcpAttachments({ + tx, + childWorkspaceId: edge.childWorkspaceId, + sourceIsParent, + now, + writtenPairs: writtenItems.map((item) => ({ + sourceWorkflowId: item.sourceWorkflowId, + targetWorkflowId: item.targetWorkflowId, + })), + }) + + // Persist / prune the stored dependent mapping. When the caller PROVIDED values, replace + // every written target's stored set (cleared/removed fields drop out so the store equals + // exactly what was applied) AND prune the archived targets' now-dead rows (their workflow + // no longer exists and has no FK to cascade). The TRANSLATED values are persisted - a + // source document id picked under a copy-resolved KB is stored as its copied counterpart, + // so the next sync (whose parent is then MAPPED via the persisted copy mapping) pre-fills + // a value that resolves in the target. Written CREATE targets persist too - they exist + // as of this sync, and their sent values (pre-configured in the mapping editor or the + // modal) must survive as the stored mapping for future syncs. Scope the inserted values + // to the delete's workflows so a value for a workflow skipped this pass (its source state + // vanished) can't be inserted without first clearing its old row and trip the unique + // constraint. When OMITTED, the store stays the source of truth (already applied above) - + // only prune archived targets, never touch the live targets' mapping. + const dependentTargetIds = new Set(writtenItems.map((item) => item.targetWorkflowId)) + if (dependentValuesProvided) { + await reconcileForkDependentValues( + tx, + edge.childWorkspaceId, + [...dependentTargetIds, ...plan.archivedTargetIds], + appliedDependentValues.filter((entry) => dependentTargetIds.has(entry.targetWorkflowId)) ) - ) - const requiredCleared = copyResult.clearedDependents.filter((field) => field.required) - const optionalCleared = copyResult.clearedDependents.filter((field) => !field.required) - if (requiredCleared.length > 0) { - needsConfiguration.push({ - workflowId: item.targetWorkflowId, - workflowName: item.sourceMeta.name, - // Surface the block names (deduped) - the field titles ("Label") aren't useful. - blocks: [...new Set(requiredCleared.map((field) => field.blockName))], - }) - } - if (optionalCleared.length > 0) { - clearedOptional.push({ - workflowName: item.sourceMeta.name, - blocks: [...new Set(optionalCleared.map((field) => field.blockName))], - }) + } else if (plan.archivedTargetIds.length > 0) { + await reconcileForkDependentValues(tx, edge.childWorkspaceId, plan.archivedTargetIds, []) } - writtenItems.push(item) - } - - // Reconcile block-identity pairs for the written source workflows: clears pairs for - // blocks the source dropped (e.g. a deleted trigger) and any stale pair from a re-created - // target, then records the live ones - so the next promote resolves these blocks to these - // same ids and never re-homes one onto an archived workflow's block. - await reconcileForkBlockPairs( - tx, - edge.childWorkspaceId, - sourceIsParent, - writtenItems.map((item) => item.sourceWorkflowId), - blockPairs - ) - // Carry chat deployments for written targets that have NO chat row yet (typically - // create-mode targets): a fresh `{target-workspace}-{workflow}-{randomnum}` identifier with - // the source's config, live once this sync's deploy lands. Targets with any existing chat - // (live or archived) are left untouched - an earlier carry-over keeps its URL on every - // subsequent sync, and a deliberately archived chat is never resurrected. Targets whose - // redeploy this sync SKIPS (required dependents cleared) are excluded: their chat would - // squat a live identifier while nothing can serve - the next successful sync carries it. - const needsConfigurationTargetIds = new Set(needsConfiguration.map((entry) => entry.workflowId)) - await copyForkChatDeployments({ - tx, - pairs: writtenItems.flatMap((item) => - needsConfigurationTargetIds.has(item.targetWorkflowId) - ? [] - : [ - { - sourceWorkflowId: item.sourceWorkflowId, - targetWorkflowId: item.targetWorkflowId, - workflowName: item.sourceMeta.name, - }, - ] - ), - targetWorkspaceName, - userId, - now, - resolveBlockId, - requestId, - }) + const archivedNames = + plan.archivedTargetIds.length > 0 + ? ( + await tx + .select({ name: workflow.name }) + .from(workflow) + .where(inArray(workflow.id, plan.archivedTargetIds)) + ).map((row) => row.name) + : [] - // Mirror workflow-as-MCP-tool attachments onto MAPPED workflow-publishing servers for the - // written pairs: missing target attachments are created, drifted metadata refreshed, and a - // detached source's counterpart archived. The deployment outbox re-derives each affected - // tool's parameter schema when the target deploys below. - const mcpAttachmentResult = await reconcileForkWorkflowMcpAttachments({ - tx, - childWorkspaceId: edge.childWorkspaceId, - sourceIsParent, - now, - writtenPairs: writtenItems.map((item) => ({ - sourceWorkflowId: item.sourceWorkflowId, - targetWorkflowId: item.targetWorkflowId, - })), - }) + const undeployEventIds: string[] = [] + const archivedSnapshots: PromoteRunWorkflowSnapshot[] = [] + for (const targetWorkflowId of plan.archivedTargetIds) { + const priorVersion = priorVersionByTarget.get(targetWorkflowId) ?? null + archivedSnapshots.push({ workflowId: targetWorkflowId, priorVersion }) + // Enqueue undeploy side-effects (webhook + MCP-tool cleanup) so an archived orphan + // doesn't leak its subscriptions/registrations - mirrors rollback's undeploy path. + await undeployWorkflow({ + workflowId: targetWorkflowId, + tx, + onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => { + if (deploymentVersionIds.length === 0) return + const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, { + workflowId: targetWorkflowId, + deploymentVersionIds, + userId, + requestId, + }) + undeployEventIds.push(eventId) + }, + }) + await tx + .update(workflow) + .set({ archivedAt: now, updatedAt: now }) + .where(eq(workflow.id, targetWorkflowId)) + } + // Archive the archived targets' chat deployments too (matching `archiveWorkflow`): a live + // chat row would keep squatting its unique identifier and serving a dead workflow. The + // undeploy side-effects above cover webhooks + MCP tools; chats have no undeploy hook. + if (plan.archivedTargetIds.length > 0) { + await tx + .update(chat) + .set({ archivedAt: now, isActive: false, updatedAt: now }) + .where(and(inArray(chat.workflowId, plan.archivedTargetIds), isNull(chat.archivedAt))) + } - // Persist / prune the stored dependent mapping. When the caller PROVIDED values, replace - // every written target's stored set (cleared/removed fields drop out so the store equals - // exactly what was applied) AND prune the archived targets' now-dead rows (their workflow - // no longer exists and has no FK to cascade). The TRANSLATED values are persisted - a - // source document id picked under a copy-resolved KB is stored as its copied counterpart, - // so the next sync (whose parent is then MAPPED via the persisted copy mapping) pre-fills - // a value that resolves in the target. Written CREATE targets persist too - they exist - // as of this sync, and their sent values (pre-configured in the mapping editor or the - // modal) must survive as the stored mapping for future syncs. Scope the inserted values - // to the delete's workflows so a value for a workflow skipped this pass (its source state - // vanished) can't be inserted without first clearing its old row and trip the unique - // constraint. When OMITTED, the store stays the source of truth (already applied above) - - // only prune archived targets, never touch the live targets' mapping. - const dependentTargetIds = new Set(writtenItems.map((item) => item.targetWorkflowId)) - if (dependentValuesProvided) { - await reconcileForkDependentValues( + const identityEntries: ForkMappingUpsert[] = writtenItems.map((item) => ({ + resourceType: 'workflow' as const, + parentResourceId: sourceIsParent ? item.sourceWorkflowId : item.targetWorkflowId, + childResourceId: sourceIsParent ? item.targetWorkflowId : item.sourceWorkflowId, + })) + // The identity upsert keys on the parent side, which on push is the TARGET. A + // source whose previously-mapped target was archived gets a freshly-generated + // target id here, so its old (stale-target) identity row wouldn't be overwritten + // and would leak a second mapping for the same source. Delete every prior identity + // row for these sources (by the source side) first so exactly one row per source + // remains - this also converges any pre-existing duplicates. + await deleteWorkflowIdentityByIds( tx, edge.childWorkspaceId, - [...dependentTargetIds, ...plan.archivedTargetIds], - appliedDependentValues.filter((entry) => dependentTargetIds.has(entry.targetWorkflowId)) + sourceIsParent ? 'parent' : 'child', + writtenItems.map((item) => item.sourceWorkflowId) ) - } else if (plan.archivedTargetIds.length > 0) { - await reconcileForkDependentValues(tx, edge.childWorkspaceId, plan.archivedTargetIds, []) - } + await upsertEdgeMappings(tx, edge.childWorkspaceId, userId, identityEntries) - const archivedNames = - plan.archivedTargetIds.length > 0 - ? ( - await tx - .select({ name: workflow.name }) - .from(workflow) - .where(inArray(workflow.id, plan.archivedTargetIds)) - ).map((row) => row.name) - : [] + await propagateCredentialAccess(tx, { + plan, + sourceWorkspaceId, + targetWorkspaceId, + targetMembers, + now, + }) - const undeployEventIds: string[] = [] - const archivedSnapshots: PromoteRunWorkflowSnapshot[] = [] - for (const targetWorkflowId of plan.archivedTargetIds) { - const priorVersion = priorVersionByTarget.get(targetWorkflowId) ?? null - archivedSnapshots.push({ workflowId: targetWorkflowId, priorVersion }) - // Enqueue undeploy side-effects (webhook + MCP-tool cleanup) so an archived orphan - // doesn't leak its subscriptions/registrations - mirrors rollback's undeploy path. - await undeployWorkflow({ - workflowId: targetWorkflowId, - tx, - onUndeployTransaction: async (innerTx, { deploymentVersionIds }) => { - if (deploymentVersionIds.length === 0) return - const eventId = await enqueueWorkflowUndeploySideEffects(innerTx, { - workflowId: targetWorkflowId, - deploymentVersionIds, - userId, - requestId, - }) - undeployEventIds.push(eventId) + const promoteRunId = await upsertPromoteRun(tx, { + childWorkspaceId: edge.childWorkspaceId, + sourceWorkspaceId, + targetWorkspaceId, + direction, + userId, + snapshot: { + updated: updatedSnapshots, + created: createdTargetIds, + archived: archivedSnapshots, }, }) - await tx - .update(workflow) - .set({ archivedAt: now, updatedAt: now }) - .where(eq(workflow.id, targetWorkflowId)) - } - // Archive the archived targets' chat deployments too (matching `archiveWorkflow`): a live - // chat row would keep squatting its unique identifier and serving a dead workflow. The - // undeploy side-effects above cover webhooks + MCP tools; chats have no undeploy hook. - if (plan.archivedTargetIds.length > 0) { - await tx - .update(chat) - .set({ archivedAt: now, isActive: false, updatedAt: now }) - .where(and(inArray(chat.workflowId, plan.archivedTargetIds), isNull(chat.archivedAt))) - } - - const identityEntries: ForkMappingUpsert[] = writtenItems.map((item) => ({ - resourceType: 'workflow' as const, - parentResourceId: direction === 'pull' ? item.sourceWorkflowId : item.targetWorkflowId, - childResourceId: direction === 'pull' ? item.targetWorkflowId : item.sourceWorkflowId, - })) - // The identity upsert keys on the parent side, which on push is the TARGET. A - // source whose previously-mapped target was archived gets a freshly-generated - // target id here, so its old (stale-target) identity row wouldn't be overwritten - // and would leak a second mapping for the same source. Delete every prior identity - // row for these sources (by the source side) first so exactly one row per source - // remains - this also converges any pre-existing duplicates. - await deleteWorkflowIdentityByIds( - tx, - edge.childWorkspaceId, - direction === 'pull' ? 'parent' : 'child', - writtenItems.map((item) => item.sourceWorkflowId) - ) - await upsertEdgeMappings(tx, edge.childWorkspaceId, userId, identityEntries) - await propagateCredentialAccess(tx, { - plan, - sourceWorkspaceId, - targetWorkspaceId, - targetMembers, - now, - }) + // A source whose active deployment vanished between plan and copy is skipped + // above, so report what was actually written - the plan totals would overstate. + const writtenSourceIds = new Set(writtenItems.map((item) => item.sourceWorkflowId)) + const skippedItems = plan.items + .filter((item) => !writtenSourceIds.has(item.sourceWorkflowId)) + .map((item) => ({ id: item.sourceWorkflowId, name: item.sourceMeta.name })) + if (skippedItems.length > 0) { + logger.warn( + `[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`, + { sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length } + ) + } - const promoteRunId = await upsertPromoteRun(tx, { - childWorkspaceId: edge.childWorkspaceId, - sourceWorkspaceId, - targetWorkspaceId, - direction, - userId, - snapshot: { - updated: updatedSnapshots, - created: createdTargetIds, - archived: archivedSnapshots, - }, + const applied: PromoteTxApplied = { + blocked: null, + promoteRunId, + deployTargetIds: writtenItems.map((item) => item.targetWorkflowId), + updated: updatedSnapshots.length, + created: createdTargetIds.length, + archived: archivedSnapshots.length, + skippedItems, + writtenNames: Object.fromEntries( + writtenItems.map((item) => [item.targetWorkflowId, item.sourceMeta.name]) + ), + updatedNames: writtenItems + .filter((item) => item.mode === 'replace') + .map((item) => item.sourceMeta.name), + createdNames: writtenItems + .filter((item) => item.mode !== 'replace') + .map((item) => item.sourceMeta.name), + archivedNames, + undeployEventIds, + needsConfiguration, + clearedOptional, + droppedReferences, + triggerUrlChanges, + copyContentPlan, + copyContentRefMaps, + copyContentBlobTasks, + mcpAttachmentServerIds: mcpAttachmentResult.affectedServerIds, + } + if (admission) { + const result: PromoteForkResult = { + promoteRunId, + updated: applied.updated, + created: applied.created, + archived: applied.archived, + redeployed: 0, + deployFailed: 0, + deployWarnings: [], + unmappedRequired: [], + blockers: [], + blocked: null, + updatedNames: applied.updatedNames, + createdNames: applied.createdNames, + archivedNames, + needsConfiguration: needsConfiguration.map(({ workflowName, blocks }) => ({ + workflowName, + blocks, + })), + clearedOptional, + droppedReferences, + triggerUrlChanges, + } + applied.operation = await admitForkSync(tx, { + admission, + targetWorkspaceId, + direction, + userId, + result, + targetIds: applied.deployTargetIds, + undeployEventIds, + mcpAttachmentServerIds: applied.mcpAttachmentServerIds, + needsConfigurationIds: needsConfigurationTargetIds, + ...(copyContentPlan + ? { + copy: { + contentPlan: copyContentPlan, + blobTasks: copyContentBlobTasks, + contentRefMaps: copyContentRefMaps ?? undefined, + deployedTargetWorkflowIds: applied.deployTargetIds, + requestId, + }, + } + : {}), + }) + } + return applied }) - // A source whose active deployment vanished between plan and copy is skipped - // above, so report what was actually written - the plan totals would overstate. - const writtenSourceIds = new Set(writtenItems.map((item) => item.sourceWorkflowId)) - const skippedItems = plan.items - .filter((item) => !writtenSourceIds.has(item.sourceWorkflowId)) - .map((item) => ({ id: item.sourceWorkflowId, name: item.sourceMeta.name })) - if (skippedItems.length > 0) { - logger.warn( - `[${requestId}] Promote skipped ${skippedItems.length} source workflow(s) whose deployment disappeared between plan and apply`, - { sourceWorkspaceId, targetWorkspaceId, skipped: skippedItems.length } - ) - } - - return { - blocked: null, - promoteRunId, - deployTargetIds: writtenItems.map((item) => item.targetWorkflowId), - updated: updatedSnapshots.length, - created: createdTargetIds.length, - archived: archivedSnapshots.length, - skippedItems, - writtenNames: Object.fromEntries( - writtenItems.map((item) => [item.targetWorkflowId, item.sourceMeta.name]) - ), - updatedNames: writtenItems - .filter((item) => item.mode === 'replace') - .map((item) => item.sourceMeta.name), - createdNames: writtenItems - .filter((item) => item.mode !== 'replace') - .map((item) => item.sourceMeta.name), - archivedNames, - undeployEventIds, - needsConfiguration, - clearedOptional, - droppedReferences, - triggerUrlChanges, - copyContentPlan, - copyContentRefMaps, - copyContentBlobTasks, - mcpAttachmentServerIds: mcpAttachmentResult.affectedServerIds, - } - }) - + if ('receipt' in txResult) { + if (!txResult.receipt.syncResult) throw new Error('Sync receipt is incomplete') + return { ...txResult.receipt.syncResult, operation: txResult.receipt, replayed: true } + } + if (txResult.blocked === null && txResult.operation?.syncResult) + return { ...txResult.operation.syncResult, operation: txResult.operation } if (txResult.blocked !== null) { return { promoteRunId: '', diff --git a/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.test.ts b/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.test.ts index ea0154ec75c..68d0fb9bbc8 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.test.ts @@ -145,6 +145,109 @@ describe('fork trigger URLs', () => { expect(changes.map((change) => change.path)).toEqual(['blk1']) }) + it('scopes repeated block IDs by source workflow while preserving the other workflow default', () => { + const secondItem = { ...item, sourceWorkflowId: 'wf-src-2', targetWorkflowId: 'wf-tgt-2' } + const state = stateWith({ trigger: { type: 'slack_webhook', name: 'Slack' } }) + const plan = buildForkTriggerPlan({ + items: [item, secondItem], + sourceStates: new Map([ + [item.sourceWorkflowId, state], + [secondItem.sourceWorkflowId, state], + ]), + resolveBlockId: (workflowId, blockId) => `${workflowId}:${blockId}`, + targetWebhooks: webhooks([ + [ + 'retiring-1', + { path: 'first-path', workflowId: item.targetWorkflowId, provider: 'slack' }, + ], + [ + 'retiring-2', + { path: 'second-path', workflowId: secondItem.targetWorkflowId, provider: 'slack' }, + ], + ]), + }) + expect( + plan.slots.map(({ sourceWorkflowId, sourceBlockId }) => ({ sourceWorkflowId, sourceBlockId })) + ).toEqual([ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'trigger' }, + { sourceWorkflowId: 'wf-src-2', sourceBlockId: 'trigger' }, + ]) + const resolved = resolveForkTriggerPaths(plan, [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'trigger', adoptPath: null }, + ]) + expect([...resolved.pathByTargetBlockId]).toEqual([['wf-tgt-2:trigger', 'second-path']]) + expect(resolved.changes).toEqual([{ workflowName: 'Prod', path: 'first-path' }]) + expect(() => + resolveForkTriggerPaths(plan, [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'trigger', adoptPath: 'second-path' }, + ]) + ).toThrow('not an adoptable path') + }) + + it.each([ + [{ sourceWorkflowId: 'unknown-workflow', sourceBlockId: 'blk2', adoptPath: null }], + [{ sourceWorkflowId: 'wf-src', sourceBlockId: 'unknown-block', adoptPath: null }], + [{ sourceWorkflowId: 'wf-src', sourceBlockId: 'blk2', adoptPath: 'unoffered-path' }], + [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk2', adoptPath: null }, + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk2', adoptPath: 'blk1' }, + ], + [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk2', adoptPath: null }, + { sourceBlockId: 'blk3', adoptPath: null }, + ], + ])('rejects invalid source-scoped trigger choices before resolving paths: %j', (...overrides) => { + expect(() => + run( + { blk2: { type: 'slack_webhook', name: 'Slack v2' } }, + webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]]), + overrides + ) + ).toThrow(expect.objectContaining({ code: 'validation' })) + }) + + it('rejects two scoped choices adopting the same retiring path', () => { + expect(() => + run( + { + blk2: { type: 'slack_webhook', name: 'Slack A' }, + blk3: { type: 'slack_webhook', name: 'Slack B' }, + }, + webhooks([['blk1', { path: 'blk1', workflowId: 'wf-tgt', provider: 'slack' }]]), + [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk2', adoptPath: 'blk1' }, + { sourceWorkflowId: 'wf-src', sourceBlockId: 'blk3', adoptPath: 'blk1' }, + ] + ) + ).toThrow('only once') + }) + + it('refuses scoped choices for a trigger that already preserves its own path', () => { + expect(() => + run( + { blk: { type: 'slack_webhook', name: 'Slack' } }, + webhooks([['blk', { path: 'stable-path', workflowId: 'wf-tgt', provider: 'slack' }]]), + [{ sourceWorkflowId: 'wf-src', sourceBlockId: 'blk', adoptPath: null }] + ) + ).toThrow('existing target path') + }) + + it('rejects an ambiguous source identity instead of assigning its choice to multiple targets', () => { + const plan = buildForkTriggerPlan({ + items: [item, { ...item, targetWorkflowId: 'another-target' }], + sourceStates: new Map([ + ['wf-src', stateWith({ trigger: { type: 'slack_webhook', name: 'Slack' } })], + ]), + resolveBlockId: (workflowId, blockId) => `${workflowId}:${blockId}`, + targetWebhooks: new Map(), + }) + expect(() => + resolveForkTriggerPaths(plan, [ + { sourceWorkflowId: 'wf-src', sourceBlockId: 'trigger', adoptPath: null }, + ]) + ).toThrow('ambiguous') + }) + it('lets an explicit null override the default and mint a new URL', () => { const { pathByTargetBlockId, changes } = run( { blk2: { type: 'slack_webhook', name: 'Slack v2' } }, diff --git a/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.ts b/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.ts index e02b46198ce..3f4d556a11b 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/trigger-urls.ts @@ -26,6 +26,7 @@ export interface ForkTriggerUrlChange { * workflow that this block can take over instead of minting a new one. */ export interface ForkTriggerSlot { + sourceWorkflowId: string sourceBlockId: string targetBlockId: string blockName: string @@ -47,6 +48,8 @@ export interface ForkTriggerPlan { /** A caller's explicit choice of which retiring URL an arriving trigger takes over. */ export interface ForkTriggerMappingInput { + /** Required by public clients; omitted only by legacy internal block-only callers. */ + sourceWorkflowId?: string sourceBlockId: string /** A path from that slot's `adoptablePaths`, or null to mint a new URL. */ adoptPath: string | null @@ -118,6 +121,7 @@ export function buildForkTriggerPlan(params: { // URL as preserved, so nobody would go looking. const provider = resolveBlockTriggerProvider(block) arriving.push({ + sourceWorkflowId: item.sourceWorkflowId, sourceBlockId, targetBlockId, blockName: block.name, @@ -150,10 +154,8 @@ export function buildForkTriggerPlan(params: { * Resolve every trigger block's final path, applying the caller's explicit choices over the * plan's defaults, and report the URLs that still retire. * - * An override is honoured only for a path the slot actually offered (same target workflow, still - * retiring), and each path can be adopted once - so a crafted payload can neither move a URL - * across workflows nor point two triggers at one path (which the unique webhook path index would - * reject at deploy time anyway, failing the whole sync). + * Source workflow/block identities require exact, unique choices from the plan. Legacy callers + * that provide only block IDs retain their existing behavior of ignoring invalid choices. */ export function resolveForkTriggerPaths( plan: ForkTriggerPlan, @@ -163,9 +165,55 @@ export function resolveForkTriggerPaths( pathByTargetBlockId: Map changes: ForkTriggerUrlChange[] } { - const overrideBySourceBlockId = new Map( - overrides.map((entry) => [entry.sourceBlockId, entry.adoptPath]) - ) + const scoped = overrides.some((entry) => entry.sourceWorkflowId !== undefined) + const identity = (source: { sourceWorkflowId?: string; sourceBlockId: string }) => + scoped ? JSON.stringify([source.sourceWorkflowId, source.sourceBlockId]) : source.sourceBlockId + const overrideBySourceIdentity = new Map() + if (scoped) { + const slotsByIdentity = new Map() + for (const slot of plan.slots) { + const key = identity(slot) + const existing = slotsByIdentity.get(key) + if (existing) existing.push(slot) + else slotsByIdentity.set(key, [slot]) + } + const selectedPaths = new Set() + for (const override of overrides) { + if (!override.sourceWorkflowId) + throw new OrchestrationError('validation', 'Trigger mappings require a source workflow ID') + const key = identity(override) + if (overrideBySourceIdentity.has(key)) + throw new OrchestrationError('validation', 'Duplicate source trigger mapping') + const slots = slotsByIdentity.get(key) + if (!slots?.length) + throw new OrchestrationError( + 'validation', + 'Trigger mapping does not address an eligible source workflow and block' + ) + if (slots.length !== 1) + throw new OrchestrationError('validation', 'Source trigger mapping is ambiguous') + const slot = slots[0] + if (slot.ownPath !== null) + throw new OrchestrationError( + 'validation', + 'A trigger with an existing target path preserves that path and cannot adopt another' + ) + if (override.adoptPath !== null) { + if (!slot.adoptablePaths.includes(override.adoptPath)) + throw new OrchestrationError( + 'validation', + 'Trigger mapping path is not an adoptable path for this source workflow and block' + ) + if (selectedPaths.has(override.adoptPath)) + throw new OrchestrationError('validation', 'A retiring path can be adopted only once') + selectedPaths.add(override.adoptPath) + } + overrideBySourceIdentity.set(key, override.adoptPath) + } + } else { + for (const override of overrides) + overrideBySourceIdentity.set(identity(override), override.adoptPath) + } const pathByTargetBlockId = new Map() const adopted = new Set() @@ -175,8 +223,9 @@ export function resolveForkTriggerPaths( pathByTargetBlockId.set(slot.targetBlockId, slot.ownPath) continue } - const requested = overrideBySourceBlockId.has(slot.sourceBlockId) - ? overrideBySourceBlockId.get(slot.sourceBlockId)! + const key = identity(slot) + const requested = overrideBySourceIdentity.has(key) + ? overrideBySourceIdentity.get(key)! : slot.defaultAdoptPath if (requested === null || requested === undefined) continue if (!slot.adoptablePaths.includes(requested)) continue @@ -193,3 +242,5 @@ export function resolveForkTriggerPaths( } return { pathByTargetBlockId, changes } } + +import { OrchestrationError } from '@/lib/core/orchestration/types' diff --git a/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts b/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts index 48ac7f9f67e..dd4cc3940d1 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts @@ -1,10 +1,10 @@ -import type { ForkRemapKind } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { ForkRemapKind } from '@/lib/workflows/references/remap-references' import { clearDependentsOnRemap, remapForkBlockType, remapForkSubBlocks, type SubBlockTransform, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' /** * Resolves a source resource reference to its copied child id, or null when the @@ -22,13 +22,21 @@ export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string * the child defines the key). */ export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform { - return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => { + return ( + subBlocks, + blockType, + canonicalModes, + onCanonicalModesChanged, + triggerMode, + preserveToolIndices + ) => { // Every resolution at fork-create IS a copy (the resolver is the copy id map), so all // remapped keys carry copy provenance - copy-faithful dependents (column picks) survive. // `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active // advanced (manual) passes through with its dependents, dormant members clear. const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', { blockType, + preserveToolIndices, canonicalModes, triggerMode, isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null, diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts index 1819baa515f..12d2479ce62 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { getBlock } from '@/blocks/registry' import { applyDependentOverrides, customBlockInputStorageKey, @@ -11,7 +10,8 @@ import { remapForkBlockType, replaceCustomBlockInputs, scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import { getBlock } from '@/blocks/registry' const PROD_BLOCK = 'custom_block_prodabc123' const UAT_BLOCK = 'custom_block_uatxyz7890' diff --git a/apps/sim/lib/api/contracts/selectors/execute.ts b/apps/sim/lib/api/contracts/selectors/execute.ts index 0e363598f5d..19b10809b4e 100644 --- a/apps/sim/lib/api/contracts/selectors/execute.ts +++ b/apps/sim/lib/api/contracts/selectors/execute.ts @@ -107,14 +107,24 @@ export const selectorOptionSchema = z id: z .string() .min(1) - .max(16 * 1024), + .max(16 * 1024) + .describe('Provider resource identifier.'), label: z .string() .min(1) - .max(16 * 1024), - meta: z.record(z.string().min(1).max(128), safeOptionMetaValueSchema).optional(), + .max(16 * 1024) + .describe('Human-readable provider resource name.'), + meta: z + .record(z.string().min(1).max(128), safeOptionMetaValueSchema) + .optional() + .describe('Safe scalar metadata for presenting or configuring this choice.'), }) .strict() + .meta({ + id: 'SelectorOption', + title: 'SelectorOption', + description: 'The SelectorOption result.', + }) export const executeSelectorResponseSchema = z.discriminatedUnion('kind', [ z diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index d8e491e9710..bf27fc9d660 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -72,6 +72,11 @@ const PAGED_LISTS = [ 'GET /api/v2/workflows/[workflowId]/versions', 'GET /api/v2/workflow-mcp-servers', 'GET /api/v2/workspaces/[workspaceId]/members', + 'GET /api/v2/workspaces/[workspaceId]/fork/children', + 'GET /api/v2/workspaces/[workspaceId]/fork/mappings', + 'GET /api/v2/workspaces/[workspaceId]/fork/resources', + 'GET /api/v2/workspaces/[workspaceId]/operations', + 'POST /api/v2/selectors/list', 'GET /api/v2/workspaces', ] as const @@ -248,6 +253,16 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/workflow-mcp-servers': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/chat-deployments': ['workspaceId', 'workflowId', 'isActive', 'sortBy', 'sortOrder'], 'GET /api/v2/workspaces/[workspaceId]/members': [], + 'GET /api/v2/workspaces/[workspaceId]/fork/children': ['sortBy', 'sortOrder'], + 'GET /api/v2/workspaces/[workspaceId]/fork/mappings': [ + 'otherWorkspaceId', + 'direction', + 'sortBy', + 'sortOrder', + ], + 'GET /api/v2/workspaces/[workspaceId]/fork/resources': ['kind', 'sortBy', 'sortOrder'], + 'GET /api/v2/workspaces/[workspaceId]/operations': ['requestId'], + 'POST /api/v2/selectors/list': ['workspaceId', 'selectorKey', 'context', 'search'], 'GET /api/v2/workspaces': ['sortBy', 'sortOrder'], } @@ -288,6 +303,10 @@ const CURSOR_BOUND_PATH_PARAMS: Record = { 'GET /api/v2/workflows/[workflowId]/runs': ['workflowId'], 'GET /api/v2/workflows/[workflowId]/versions': ['workflowId'], 'GET /api/v2/workspaces/[workspaceId]/members': ['workspaceId'], + 'GET /api/v2/workspaces/[workspaceId]/fork/children': ['workspaceId'], + 'GET /api/v2/workspaces/[workspaceId]/fork/mappings': ['workspaceId'], + 'GET /api/v2/workspaces/[workspaceId]/fork/resources': ['workspaceId'], + 'GET /api/v2/workspaces/[workspaceId]/operations': ['workspaceId'], } /** diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 84593b41f28..83a22a67f2a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -27,6 +27,7 @@ import { WORKSPACE_ERRORS, withRequestBodyErrors, } from '@/lib/api/contracts/v2/openapi/shared' +import { workspaceSyncOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/workspace-sync' import { EXECUTE_OPTION_CONSTRAINTS, v2ActivateWorkflowVersionContract, @@ -958,12 +959,17 @@ const declaredRoutes = [ applicationOperation: workflowOperations.export, operationId: 'exportWorkflow', summary: 'Export Workflow', - description: `Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. Exporting records an audit event. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, + 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_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow export payload.'), }), { - query: v2ExportWorkflowContract.query, + query: documentedSchema( + v2ExportWorkflowContract.query, + 'ExportWorkflowQuery', + 'Export workflow query', + 'Export reference options.' + ), params: v2ExportWorkflowContract.params, response: documentedSchema( v2ExportWorkflowContract.response.schema, @@ -995,13 +1001,18 @@ const declaredRoutes = [ applicationOperation: workflowOperations.import, operationId: 'importWorkflow', summary: 'Import Workflow', - description: `Create a workflow from a portable export object, bare state, or JSON string. ${FOLDER_TREE_TOO_LARGE}`, + 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. ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_MUTATION_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The imported workflow.'), }), { query: v2ImportWorkflowContract.query, - body: v2ImportWorkflowContract.body, + body: documentedSchema( + v2ImportWorkflowContract.body, + 'ImportWorkflowBody', + 'Import workflow input', + 'Workflow document, destination, and optional reviewed mappings.' + ), response: documentedSchema( v2ImportWorkflowContract.response.schema, 'ImportWorkflowResponse', @@ -1475,6 +1486,11 @@ export const workflowsOpenApiDocument = defineOpenApiDocument({ }, servers: [{ url: 'https://www.sim.ai', description: 'Production' }], tags: [ + { + name: 'Workspace Sync', + description: + 'Portable workflow configuration, workspace forks, push and pull, and durable operation status.', + }, { name: 'Workflows', description: @@ -1490,5 +1506,5 @@ export const workflowsOpenApiDocument = defineOpenApiDocument({ headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, - routes, + routes: [...routes, ...workspaceSyncOpenApiRoutes], }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/workspace-sync.ts b/apps/sim/lib/api/contracts/v2/openapi/workspace-sync.ts new file mode 100644 index 00000000000..239869709ce --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/workspace-sync.ts @@ -0,0 +1,731 @@ +import { + documentedSchema, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { v2GetSelectorContract, v2ListSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { v2PreviewWorkflowImportContract } 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 { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const workspaceSyncOpenApiRoutes = [ + defineOpenApiRoute( + v2PreviewWorkspaceForkContract, + { + applicationOperation: forkOperations.preview, + operationId: 'previewWorkspaceFork', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2PreviewWorkspaceForkContract.params, + 'PreviewWorkspaceForkParams', + 'PreviewWorkspaceFork params', + 'The params for this operation.' + ), + query: documentedSchema( + v2PreviewWorkspaceForkContract.query, + 'PreviewWorkspaceForkQuery', + 'PreviewWorkspaceFork query', + 'The query for this operation.' + ), + body: documentedSchema( + v2PreviewWorkspaceForkContract.body, + 'PreviewWorkspaceForkBody', + 'PreviewWorkspaceFork body', + 'The body for this operation.' + ), + response: documentedSchema( + v2PreviewWorkspaceForkContract.response.schema, + 'PreviewWorkspaceForkResponse', + 'PreviewWorkspaceFork response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2ForkWorkspaceContract, + { + applicationOperation: forkOperations.create, + operationId: 'forkWorkspace', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2ForkWorkspaceContract.params, + 'ForkWorkspaceParams', + 'ForkWorkspace params', + 'The params for this operation.' + ), + query: documentedSchema( + v2ForkWorkspaceContract.query, + 'ForkWorkspaceQuery', + 'ForkWorkspace query', + 'The query for this operation.' + ), + body: documentedSchema( + v2ForkWorkspaceContract.body, + 'ForkWorkspaceBody', + 'ForkWorkspace body', + 'The body for this operation.' + ), + response: documentedSchema( + v2ForkWorkspaceContract.response.schema, + 'ForkWorkspaceResponse', + 'ForkWorkspace response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2PreviewWorkspacePushContract, + { + applicationOperation: forkOperations.syncPreview, + operationId: 'previewWorkspacePush', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2PreviewWorkspacePushContract.params, + 'PreviewWorkspacePushParams', + 'PreviewWorkspacePush params', + 'The params for this operation.' + ), + query: documentedSchema( + v2PreviewWorkspacePushContract.query, + 'PreviewWorkspacePushQuery', + 'PreviewWorkspacePush query', + 'The query for this operation.' + ), + body: documentedSchema( + v2PreviewWorkspacePushContract.body, + 'PreviewWorkspacePushBody', + 'PreviewWorkspacePush body', + 'The body for this operation.' + ), + response: documentedSchema( + v2PreviewWorkspacePushContract.response.schema, + 'PreviewWorkspacePushResponse', + 'PreviewWorkspacePush response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2PushWorkspaceContract, + { + applicationOperation: forkOperations.sync, + operationId: 'pushWorkspace', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2PushWorkspaceContract.params, + 'PushWorkspaceParams', + 'PushWorkspace params', + 'The params for this operation.' + ), + query: documentedSchema( + v2PushWorkspaceContract.query, + 'PushWorkspaceQuery', + 'PushWorkspace query', + 'The query for this operation.' + ), + body: documentedSchema( + v2PushWorkspaceContract.body, + 'PushWorkspaceBody', + 'PushWorkspace body', + 'The body for this operation.' + ), + response: documentedSchema( + v2PushWorkspaceContract.response.schema, + 'PushWorkspaceResponse', + 'PushWorkspace response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2PreviewWorkspacePullContract, + { + applicationOperation: forkOperations.syncPreview, + operationId: 'previewWorkspacePull', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2PreviewWorkspacePullContract.params, + 'PreviewWorkspacePullParams', + 'PreviewWorkspacePull params', + 'The params for this operation.' + ), + query: documentedSchema( + v2PreviewWorkspacePullContract.query, + 'PreviewWorkspacePullQuery', + 'PreviewWorkspacePull query', + 'The query for this operation.' + ), + body: documentedSchema( + v2PreviewWorkspacePullContract.body, + 'PreviewWorkspacePullBody', + 'PreviewWorkspacePull body', + 'The body for this operation.' + ), + response: documentedSchema( + v2PreviewWorkspacePullContract.response.schema, + 'PreviewWorkspacePullResponse', + 'PreviewWorkspacePull response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2PullWorkspaceContract, + { + applicationOperation: forkOperations.sync, + operationId: 'pullWorkspace', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2PullWorkspaceContract.params, + 'PullWorkspaceParams', + 'PullWorkspace params', + 'The params for this operation.' + ), + query: documentedSchema( + v2PullWorkspaceContract.query, + 'PullWorkspaceQuery', + 'PullWorkspace query', + 'The query for this operation.' + ), + body: documentedSchema( + v2PullWorkspaceContract.body, + 'PullWorkspaceBody', + 'PullWorkspace body', + 'The body for this operation.' + ), + response: documentedSchema( + v2PullWorkspaceContract.response.schema, + 'PullWorkspaceResponse', + 'PullWorkspace response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2GetWorkspaceForkAvailabilityContract, + { + applicationOperation: forkOperations.discover, + operationId: 'getWorkspaceForkAvailability', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2GetWorkspaceForkAvailabilityContract.params, + 'GetWorkspaceForkAvailabilityParams', + 'GetWorkspaceForkAvailability params', + 'The params for this operation.' + ), + query: documentedSchema( + v2GetWorkspaceForkAvailabilityContract.query, + 'GetWorkspaceForkAvailabilityQuery', + 'GetWorkspaceForkAvailability query', + 'The query for this operation.' + ), + response: documentedSchema( + v2GetWorkspaceForkAvailabilityContract.response.schema, + 'GetWorkspaceForkAvailabilityResponse', + 'GetWorkspaceForkAvailability response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2GetWorkspaceForkLineageContract, + { + applicationOperation: forkOperations.discover, + operationId: 'getWorkspaceForkLineage', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2GetWorkspaceForkLineageContract.params, + 'GetWorkspaceForkLineageParams', + 'GetWorkspaceForkLineage params', + 'The params for this operation.' + ), + query: documentedSchema( + v2GetWorkspaceForkLineageContract.query, + 'GetWorkspaceForkLineageQuery', + 'GetWorkspaceForkLineage query', + 'The query for this operation.' + ), + response: documentedSchema( + v2GetWorkspaceForkLineageContract.response.schema, + 'GetWorkspaceForkLineageResponse', + 'GetWorkspaceForkLineage response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2ListWorkspaceForkChildrenContract, + { + applicationOperation: forkOperations.discover, + operationId: 'listWorkspaceForkChildren', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2ListWorkspaceForkChildrenContract.params, + 'ListWorkspaceForkChildrenParams', + 'ListWorkspaceForkChildren params', + 'The params for this operation.' + ), + query: documentedSchema( + v2ListWorkspaceForkChildrenContract.query, + 'ListWorkspaceForkChildrenQuery', + 'ListWorkspaceForkChildren query', + 'The query for this operation.' + ), + response: documentedSchema( + v2ListWorkspaceForkChildrenContract.response.schema, + 'ListWorkspaceForkChildrenResponse', + 'ListWorkspaceForkChildren response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2ListWorkspaceForkResourcesContract, + { + applicationOperation: forkOperations.discover, + operationId: 'listWorkspaceForkResources', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2ListWorkspaceForkResourcesContract.params, + 'ListWorkspaceForkResourcesParams', + 'ListWorkspaceForkResources params', + 'The params for this operation.' + ), + query: documentedSchema( + v2ListWorkspaceForkResourcesContract.query, + 'ListWorkspaceForkResourcesQuery', + 'ListWorkspaceForkResources query', + 'The query for this operation.' + ), + response: documentedSchema( + v2ListWorkspaceForkResourcesContract.response.schema, + 'ListWorkspaceForkResourcesResponse', + 'ListWorkspaceForkResources response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2GetWorkspaceForkMappingsContract, + { + applicationOperation: forkOperations.mappingsRead, + operationId: 'getWorkspaceForkMappings', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2GetWorkspaceForkMappingsContract.params, + 'GetWorkspaceForkMappingsParams', + 'GetWorkspaceForkMappings params', + 'The params for this operation.' + ), + query: documentedSchema( + v2GetWorkspaceForkMappingsContract.query, + 'GetWorkspaceForkMappingsQuery', + 'GetWorkspaceForkMappings query', + 'The query for this operation.' + ), + response: documentedSchema( + v2GetWorkspaceForkMappingsContract.response.schema, + 'GetWorkspaceForkMappingsResponse', + 'GetWorkspaceForkMappings response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2UpdateWorkspaceForkMappingsContract, + { + applicationOperation: forkOperations.mappingsUpdate, + operationId: 'updateWorkspaceForkMappings', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2UpdateWorkspaceForkMappingsContract.params, + 'UpdateWorkspaceForkMappingsParams', + 'UpdateWorkspaceForkMappings params', + 'The params for this operation.' + ), + query: documentedSchema( + v2UpdateWorkspaceForkMappingsContract.query, + 'UpdateWorkspaceForkMappingsQuery', + 'UpdateWorkspaceForkMappings query', + 'The query for this operation.' + ), + body: documentedSchema( + v2UpdateWorkspaceForkMappingsContract.body, + 'UpdateWorkspaceForkMappingsBody', + 'UpdateWorkspaceForkMappings body', + 'The body for this operation.' + ), + response: documentedSchema( + v2UpdateWorkspaceForkMappingsContract.response.schema, + 'UpdateWorkspaceForkMappingsResponse', + 'UpdateWorkspaceForkMappings response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2RollbackWorkspaceForkContract, + { + applicationOperation: forkOperations.rollback, + operationId: 'rollbackWorkspaceFork', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2RollbackWorkspaceForkContract.params, + 'RollbackWorkspaceForkParams', + 'RollbackWorkspaceFork params', + 'The params for this operation.' + ), + query: documentedSchema( + v2RollbackWorkspaceForkContract.query, + 'RollbackWorkspaceForkQuery', + 'RollbackWorkspaceFork query', + 'The query for this operation.' + ), + body: documentedSchema( + v2RollbackWorkspaceForkContract.body, + 'RollbackWorkspaceForkBody', + 'RollbackWorkspaceFork body', + 'The body for this operation.' + ), + response: documentedSchema( + v2RollbackWorkspaceForkContract.response.schema, + 'RollbackWorkspaceForkResponse', + 'RollbackWorkspaceFork response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2UnlinkWorkspaceForkContract, + { + applicationOperation: forkOperations.unlink, + operationId: 'unlinkWorkspaceFork', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2UnlinkWorkspaceForkContract.params, + 'UnlinkWorkspaceForkParams', + 'UnlinkWorkspaceFork params', + 'The params for this operation.' + ), + query: documentedSchema( + v2UnlinkWorkspaceForkContract.query, + 'UnlinkWorkspaceForkQuery', + 'UnlinkWorkspaceFork query', + 'The query for this operation.' + ), + body: documentedSchema( + v2UnlinkWorkspaceForkContract.body, + 'UnlinkWorkspaceForkBody', + 'UnlinkWorkspaceFork body', + 'The body for this operation.' + ), + response: documentedSchema( + v2UnlinkWorkspaceForkContract.response.schema, + 'UnlinkWorkspaceForkResponse', + 'UnlinkWorkspaceFork response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2UpdateWorkspaceForkExclusionsContract, + { + applicationOperation: forkOperations.exclusions, + operationId: 'updateWorkspaceForkExclusions', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The workspace operation result.' }, + }, + { + params: documentedSchema( + v2UpdateWorkspaceForkExclusionsContract.params, + 'UpdateWorkspaceForkExclusionsParams', + 'UpdateWorkspaceForkExclusions params', + 'The params for this operation.' + ), + query: documentedSchema( + v2UpdateWorkspaceForkExclusionsContract.query, + 'UpdateWorkspaceForkExclusionsQuery', + 'UpdateWorkspaceForkExclusions query', + 'The query for this operation.' + ), + body: documentedSchema( + v2UpdateWorkspaceForkExclusionsContract.body, + 'UpdateWorkspaceForkExclusionsBody', + 'UpdateWorkspaceForkExclusions body', + 'The body for this operation.' + ), + response: documentedSchema( + v2UpdateWorkspaceForkExclusionsContract.response.schema, + 'UpdateWorkspaceForkExclusionsResponse', + 'UpdateWorkspaceForkExclusions response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2PreviewWorkflowImportContract, + { + applicationOperation: workflowOperations.importPreview, + operationId: 'previewWorkflowImport', + 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.`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The operation result.' }, + }, + { + query: documentedSchema( + v2PreviewWorkflowImportContract.query, + 'PreviewWorkflowImportQuery', + 'PreviewWorkflowImport query', + 'The query for this operation.' + ), + body: v2PreviewWorkflowImportContract.body, + response: documentedSchema( + v2PreviewWorkflowImportContract.response.schema, + 'PreviewWorkflowImportResponse', + 'PreviewWorkflowImport response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2ListSelectorContract, + { + applicationOperation: selectorOperations.execute, + operationId: 'listSelector', + 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_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The operation result.' }, + }, + { + query: documentedSchema( + v2ListSelectorContract.query, + 'ListSelectorQuery', + 'ListSelector query', + 'The query for this operation.' + ), + body: documentedSchema( + v2ListSelectorContract.body, + 'ListSelectorBody', + 'ListSelector body', + 'The body for this operation.' + ), + response: documentedSchema( + v2ListSelectorContract.response.schema, + 'ListSelectorResponse', + 'ListSelector response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2GetSelectorContract, + { + applicationOperation: selectorOperations.execute, + operationId: 'getSelector', + summary: 'Get Selector Option', + description: `Resolve a workspace configuration option by its provider identifier and declared dependencies. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The operation result.' }, + }, + { + query: documentedSchema( + v2GetSelectorContract.query, + 'GetSelectorQuery', + 'GetSelector query', + 'The query for this operation.' + ), + body: documentedSchema( + v2GetSelectorContract.body, + 'GetSelectorBody', + 'GetSelector body', + 'The body for this operation.' + ), + response: documentedSchema( + v2GetSelectorContract.response.schema, + 'GetSelectorResponse', + 'GetSelector response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2GetWorkspaceOperationContract, + { + applicationOperation: workspaceOperations.read, + operationId: 'getWorkspaceOperation', + 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.`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The operation result.' }, + }, + { + query: documentedSchema( + v2GetWorkspaceOperationContract.query, + 'GetWorkspaceOperationQuery', + 'GetWorkspaceOperation query', + 'The query for this operation.' + ), + params: documentedSchema( + v2GetWorkspaceOperationContract.params, + 'GetWorkspaceOperationParams', + 'GetWorkspaceOperation params', + 'The params for this operation.' + ), + response: documentedSchema( + v2GetWorkspaceOperationContract.response.schema, + 'GetWorkspaceOperationResponse', + 'GetWorkspaceOperation response', + 'The response for this operation.' + ), + } + ), + defineOpenApiRoute( + v2ListWorkspaceOperationsContract, + { + applicationOperation: workspaceOperations.read, + operationId: 'listWorkspaceOperations', + summary: 'List Workspace Operations', + description: `Page committed operations newest first. Filter by the original request ID to reconcile an uncertain mutation response.`, + tags: ['Workspace Sync'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'The operation result.' }, + }, + { + query: documentedSchema( + v2ListWorkspaceOperationsContract.query, + 'ListWorkspaceOperationsQuery', + 'ListWorkspaceOperations query', + 'The query for this operation.' + ), + params: documentedSchema( + v2ListWorkspaceOperationsContract.params, + 'ListWorkspaceOperationsParams', + 'ListWorkspaceOperations params', + 'The params for this operation.' + ), + response: documentedSchema( + v2ListWorkspaceOperationsContract.response.schema, + 'ListWorkspaceOperationsResponse', + 'ListWorkspaceOperations response', + 'The response for this operation.' + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/selectors.ts b/apps/sim/lib/api/contracts/v2/selectors.ts new file mode 100644 index 00000000000..250af323a67 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/selectors.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { selectorContextSchema, selectorOptionSchema } from '@/lib/api/contracts/selectors/execute' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { type ServerSelectorKey, selectorManifest } from '@/lib/selectors/manifest' + +const INTERNAL_CATALOG_SELECTORS = new Set([ + 'workspace.credentialProviders', + 'workspace.rawSecretNames', + 'workspace.credentialGroupProviders', + 'workspace.organizationMcpProviders', +]) + +const workspaceSelectorKeys = Object.entries(selectorManifest) + .filter( + ([key, entry]) => + !INTERNAL_CATALOG_SELECTORS.has(key) && + entry.classification !== 'local' && + entry.scopeKinds.some((kind) => kind === 'workspace') + ) + .map(([key]) => key as ServerSelectorKey) + +export const v2SelectorInputSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Explicit current workspace scope.'), + selectorKey: z + .enum(workspaceSelectorKeys as [ServerSelectorKey, ...ServerSelectorKey[]]) + .describe('Selector key returned by an import or sync preview, for example gmail.labels.') + .describe('Registered selector key for discovering this field’s destination options.'), + context: selectorContextSchema + .default({}) + .describe( + 'Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.' + ), + }) + .strict() + +export const v2ListSelectorBodySchema = v2SelectorInputSchema + .extend({ + search: z.string().min(1).max(1024).optional().describe('Provider option search text.'), + cursor: z + .string() + .min(1) + .max(32 * 1024) + .optional() + .describe('Opaque continuation cursor returned by the preceding page.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(50) + .describe('Maximum number of items to return on one page.'), + }) + .strict() +export const v2GetSelectorBodySchema = v2SelectorInputSchema + .extend({ + id: z + .string() + .min(1) + .max(16 * 1024) + .describe('Provider resource identifier to resolve.') + .describe('Resource identifier.'), + }) + .strict() + +export const v2ListSelectorContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/selectors/list', + query: noInputSchema, + body: v2ListSelectorBodySchema, + response: { + mode: 'json', + schema: z.object({ + data: z + .array(selectorOptionSchema) + .max(100) + .describe('Requested options or operation result.'), + nextCursor: z + .string() + .max(32 * 1024) + .nullable() + .describe( + 'Opaque cursor for the next page. Send it back as `cursor`; null means there is nothing further to fetch. Never construct one yourself.' + ), + truncated: z + .boolean() + .describe('Whether the provider returned only a bounded subset of its options.'), + }), + }, +}) +export const v2GetSelectorContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/selectors/get', + query: noInputSchema, + body: v2GetSelectorBodySchema, + response: { mode: 'json', schema: v2DataResponse(selectorOptionSchema.nullable()) }, +}) +export type V2ListSelectorBody = z.input +export type V2GetSelectorBody = z.input +export type V2ListSelectorResponse = z.output +export type V2GetSelectorResponse = z.output diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 13457744111..4138e0363e1 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -50,6 +50,12 @@ import { v2SearchSchema, v2SortFields, } from '@/lib/api/contracts/v2/shared' +import { v2OperationReportSchema } from '@/lib/api/contracts/v2/workspace-operations' +import { + portableResourceKindSchema, + referenceOccurrenceSchema, + workflowReferenceManifestSchema, +} from '@/lib/api/contracts/workflow-references' import { cancelWorkflowExecutionReasonSchema, workflowExecutionPausedDetailSchema, @@ -1893,6 +1899,11 @@ export const v2CancelWorkflowRunContract = defineRouteContract({ export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema .extend({ + referenceManifest: workflowReferenceManifestSchema + .optional() + .describe( + 'Versioned non-secret identifiers and registered source field occurrences for mapped import.' + ), version: v1WorkflowExportPayloadSchema.shape.version.describe( 'Workflow export format version.' ), @@ -1935,7 +1946,7 @@ export const v2WorkflowExportPayloadSchema = v1WorkflowExportPayloadSchema 'Portable, secret-sanitized workflow export. Workspace-scoped bindings must be selected again after import.', }) -export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema +export const v2ImportWorkflowBaseBodySchema = v1ImportWorkflowBodySchema .omit({ folderId: true, name: true, description: true }) .extend({ workspaceId: v1ImportWorkflowBodySchema.shape.workspaceId.describe( @@ -1978,6 +1989,79 @@ export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema .optional() .describe('Override for the imported workflow description.'), }) + .extend({ + mappings: z + .array( + z + .object({ + kind: portableResourceKindSchema.describe('Resource or operation kind.'), + sourceId: z + .string() + .min(1) + .max(4096) + .describe( + 'Untrusted source reference label; imports never use it to authorize or query a source workspace.' + ), + targetId: z + .string() + .min(1) + .max(4096) + .nullable() + .describe('Authorized destination identifier, or null to clear the mapping.'), + }) + .strict() + ) + .max(5000) + .optional() + .describe('Mappings keyed by resource type and source identifier.'), + bindings: z + .array( + referenceOccurrenceSchema + .extend({ + kind: portableResourceKindSchema.describe('Resource or operation kind.'), + targetId: z + .string() + .min(1) + .max(4096) + .nullable() + .describe('Authorized destination identifier, or null to clear the mapping.'), + valuePath: referenceOccurrenceSchema.shape.valuePath.default([]), + encoding: referenceOccurrenceSchema.shape.encoding.default('scalar'), + }) + .strict() + ) + .max(5000) + .optional() + .describe('Resolved and unresolved source occurrences with their destination selections.'), + dependentValues: z + .array( + z + .object({ + blockId: z + .string() + .min(1) + .max(256) + .describe('Source block identifier before graph ID regeneration.'), + subBlockKey: z + .string() + .min(1) + .max(256) + .describe( + 'Registered source field key, including the tool index for nested Agent fields.' + ), + value: z + .string() + .max(16 * 1024) + .describe('Destination value for the registered dependent field.'), + }) + .strict() + ) + .max(2000) + .optional() + .describe( + 'Destination-dependent choices keyed by source workflow, block, and field identities.' + ), + }) .strict() .meta({ id: 'ImportWorkflowRequest', @@ -1992,12 +2076,186 @@ export const v2ImportWorkflowBodySchema = v1ImportWorkflowBodySchema ], }) +export const v2ImportWorkflowBodySchema = v2ImportWorkflowBaseBodySchema + .extend({ + requestId: z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9._:-]+$/) + .optional() + .describe( + 'Stable client-generated retry ID. Reuse it after uncertain completion; never submit a fresh ID to retry.' + ) + .describe('Stable client request ID for reconciliation and identical retries.'), + previewFingerprint: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional() + .describe('Fingerprint returned by the last import preview.') + .describe('Fingerprint of the reviewed preview and its choices.'), + }) + .strict() + .superRefine((body, ctx) => { + if ( + [ + body.mappings, + body.bindings, + body.dependentValues, + body.requestId, + body.previewFingerprint, + ].some((value) => value !== undefined) + ) { + if (!body.requestId) + ctx.addIssue({ + code: 'custom', + path: ['requestId'], + message: 'Mapped imports require requestId', + }) + if (!body.previewFingerprint) + ctx.addIssue({ + code: 'custom', + path: ['previewFingerprint'], + message: 'Mapped imports require previewFingerprint', + }) + } + }) + +export const v2ImportBindingResolutionSchema = z.object({ + kind: portableResourceKindSchema.describe('Resource or operation kind.'), + sourceId: z + .string() + .max(4096) + .describe( + 'Untrusted source reference label; imports never use it to authorize or query a source workspace.' + ), + targetId: z + .string() + .max(4096) + .nullable() + .describe('Authorized destination identifier, or null to clear the mapping.'), + required: z + .boolean() + .describe('Whether the reference or configuration is required for this operation.'), + occurrence: referenceOccurrenceSchema.describe( + 'Registered source field occurrence addressed by this binding.' + ), +}) +export const v2ImportConfigurationFieldSchema = z.object({ + blockId: z + .string() + .min(1) + .max(256) + .describe('Source block identifier before graph ID regeneration.'), + subBlockKey: z + .string() + .min(1) + .max(1024) + .describe('Registered source field key, including the tool index for nested Agent fields.'), + title: z.string().max(1024).describe('Human-readable configuration field label.'), + required: z + .boolean() + .describe('Whether the reference or configuration is required for this operation.'), + configured: z + .boolean() + .describe('Whether this destination field currently has a nonempty value.'), + multiSelect: z + .boolean() + .optional() + .describe('Whether the field accepts comma-separated selections.'), + selectorKey: z + .string() + .max(256) + .optional() + .describe('Registered selector key for discovering this field’s destination options.'), + context: z + .record(z.string().max(256), z.string().max(16384)) + .describe('Allowlisted selector dependencies scoped to the destination workspace.'), + requiresAuthentication: z + .boolean() + .describe('Whether a human must connect the provider before choices can be discovered.'), +}) +export const v2PreviewWorkflowImportContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/import/preview', + query: noInputSchema, + body: v2ImportWorkflowBaseBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + previewFingerprint: z + .string() + .length(64) + .describe('Fingerprint of the reviewed preview and its choices.'), + ready: z + .boolean() + .describe( + 'Whether the operation passes its current apply or deployment readiness checks.' + ), + bindings: z + .array(v2ImportBindingResolutionSchema) + .max(10000) + .describe( + 'Resolved and unresolved source occurrences with their destination selections.' + ), + unresolvedBindings: z + .array(v2ImportBindingResolutionSchema) + .max(10000) + .describe( + 'Source references that still require destination mappings or explicit copy choices.' + ), + configuration: z + .array(v2ImportConfigurationFieldSchema) + .max(10000) + .describe('Dependent fields that may need destination-specific values.'), + unresolvedConfiguration: z + .array(v2ImportConfigurationFieldSchema) + .max(10000) + .describe('Required destination configuration that remains empty.'), + discovery: z + .array( + z.object({ + kind: z.string().max(256).describe('Resource or operation kind.'), + command: z + .string() + .max(1024) + .describe('CLI command to enumerate destination candidates.'), + humanAuthorizationMayBeRequired: z + .boolean() + .describe('Whether discovery may require a human OAuth authorization step.'), + }) + ) + .max(32) + .describe('CLI operations for discovering suitable destination resources.'), + }) + .meta({ + id: 'PreviewWorkflowImportResult', + title: 'PreviewWorkflowImportResult', + description: 'The PreviewWorkflowImportResult result.', + }) + ), + }, +}) +export type V2ImportWorkflowBody = z.input +export type V2PreviewWorkflowImportBody = z.input + export const v2ImportWorkflowDataSchema = z .object({ - id: z.string().describe('Identifier of the imported workflow.'), - name: z.string().describe('Imported workflow name.'), + id: z + .string() + .describe('Identifier of the imported workflow.') + .describe('Resource identifier.'), + name: z + .string() + .describe('Imported workflow name.') + .describe('Display name of the workflow or workspace.'), description: z.string().nullable().describe('Imported workflow description.'), - workspaceId: z.string().describe('Workspace that owns the imported workflow.'), + workspaceId: z + .string() + .describe('Workspace that owns the imported workflow.') + .describe('Explicit current workspace scope.'), folderPath: v2FolderPathSchema.describe('Canonical containing-folder path.'), createdAt: z .string() @@ -2008,6 +2266,7 @@ export const v2ImportWorkflowDataSchema = z .describe('ISO 8601 timestamp when the workflow was last updated.') .meta({ format: 'date-time' }), }) + .extend(v2OperationReportSchema.omit({ workspaceId: true }).partial().shape) .meta({ id: 'ImportedWorkflow', title: 'Imported workflow', @@ -2017,7 +2276,15 @@ export const v2ImportWorkflowDataSchema = z export const v2ExportWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[workflowId]/export', - query: noInputSchema, + query: z + .object({ + includeReferences: booleanQueryFlagSchema + .optional() + .describe( + 'Include non-secret resource identifiers and source field occurrences for mapped imports.' + ), + }) + .strict(), params: v2WorkflowIdParamsSchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/workspace-fork.test.ts b/apps/sim/lib/api/contracts/v2/workspace-fork.test.ts new file mode 100644 index 00000000000..2a36824eba4 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspace-fork.test.ts @@ -0,0 +1,79 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { + v2SyncApplyBodySchema, + v2SyncPreviewBodySchema, + v2SyncPreviewDataSchema, + v2SyncTriggerMappingSchema, +} from '@/lib/api/contracts/v2/workspace-fork' + +const trigger = { + sourceWorkflowId: 'source-workflow', + sourceBlockId: 'source-trigger', + adoptPath: 'retiring-path', +} + +describe('v2 sync trigger contracts', () => { + it('requires stable source workflow and block identities on both preview and apply', () => { + const preview = { otherWorkspaceId: 'other-workspace', triggerMappings: [trigger] } + expect(v2SyncPreviewBodySchema.parse(preview).triggerMappings).toEqual([trigger]) + const apply = { + ...preview, + requestId: 'request', + previewFingerprint: 'a'.repeat(64), + confirm: true, + } + expect(v2SyncApplyBodySchema.parse(apply).triggerMappings).toEqual([trigger]) + const blockOnly = [{ sourceBlockId: trigger.sourceBlockId, adoptPath: trigger.adoptPath }] + expect( + v2SyncPreviewBodySchema.safeParse({ ...preview, triggerMappings: blockOnly }).success + ).toBe(false) + expect(v2SyncApplyBodySchema.safeParse({ ...apply, triggerMappings: blockOnly }).success).toBe( + false + ) + }) + + it.each(['targetWorkflowId', 'targetBlockId', 'unknown'])( + 'rejects unknown nested choice field %s', + (field) => { + expect(v2SyncTriggerMappingSchema.safeParse({ ...trigger, [field]: 'value' }).success).toBe( + false + ) + } + ) + + it('accepts an explicit choice to allocate a new path', () => { + expect(v2SyncTriggerMappingSchema.parse({ ...trigger, adoptPath: null }).adoptPath).toBeNull() + }) + + it('preserves public source trigger candidates and refuses generated target IDs in their shape', () => { + const slot = { + sourceWorkflowId: trigger.sourceWorkflowId, + sourceBlockId: trigger.sourceBlockId, + blockName: 'Slack trigger', + workflowName: 'Workflow', + ownPath: null, + adoptablePaths: ['retiring-path'], + defaultAdoptPath: 'retiring-path', + } + const preview = { + previewFingerprint: 'a'.repeat(64), + sourceWorkspaceId: 'source-workspace', + targetWorkspaceId: 'target-workspace', + ready: true, + workflows: [], + unresolvedBindings: [], + configuration: [], + excludedTargets: [], + triggerSlots: [slot], + triggerUrlChanges: [], + } + expect(v2SyncPreviewDataSchema.parse(preview).triggerSlots).toEqual([slot]) + expect( + v2SyncPreviewDataSchema.safeParse({ + ...preview, + triggerSlots: [{ ...slot, targetBlockId: 'generated-id' }], + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/workspace-fork.ts b/apps/sim/lib/api/contracts/v2/workspace-fork.ts new file mode 100644 index 00000000000..647acc15872 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspace-fork.ts @@ -0,0 +1,770 @@ +import { z } from 'zod' +import { noInputSchema, workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, +} from '@/lib/api/contracts/v2/shared' +import { v2OperationReportSchema } from '@/lib/api/contracts/v2/workspace-operations' +import { + forkMappableResourceTypeSchema, + forkRemapKindSchema, + forkResourceSelectionSchema, + promoteCopyResourcesSchema, +} from '@/lib/api/contracts/workspace-fork' + +export const v2ForkParamsSchema = z + .object({ workspaceId: workspaceIdSchema.describe('Explicit current workspace scope.') }) + .strict() +export const v2ForkRequestIdSchema = z + .string() + .trim() + .min(1) + .max(128) + .describe( + 'Stable client request ID. Reuse it with identical inputs after an uncertain response; changed inputs return 409.' + ) +export const v2ForkFingerprintSchema = z + .string() + .regex(/^[a-f0-9]{64}$/) + .describe( + 'Fingerprint from a preview with the same choices. A changed plan returns 409; request a new preview.' + ) +export const v2ForkMappingSchema = z + .object({ + resourceType: forkMappableResourceTypeSchema.describe( + 'Resource type stored on the canonical parent/child edge.' + ), + sourceId: z + .string() + .min(1) + .max(4096) + .describe('Source resource identifier in the canonical source workspace.'), + targetId: z + .string() + .min(1) + .max(4096) + .nullable() + .describe('Authorized destination identifier, or null to clear the mapping.'), + }) + .strict() +export const v2ForkDependentValueSchema = z + .object({ + sourceWorkflowId: workflowIdSchema.describe('Workflow identifier in the source workspace.'), + sourceBlockId: z.string().min(1).max(256).describe('Block identifier in the source workflow.'), + subBlockKey: z + .string() + .min(1) + .max(1024) + .describe('Registered source field key, including the tool index for nested Agent fields.'), + value: z.string().max(65536).describe('Destination value for the registered dependent field.'), + }) + .strict() +export const v2SyncTriggerMappingSchema = z + .object({ + sourceWorkflowId: workflowIdSchema.describe('Workflow identifier in the source workspace.'), + sourceBlockId: z + .string() + .min(1) + .max(256) + .describe('Source trigger block identifier from the sync preview.'), + adoptPath: z + .string() + .min(1) + .max(4096) + .nullable() + .describe('An adoptable path offered for this trigger, or null to allocate a new path.'), + }) + .strict() +export type V2SyncTriggerMapping = z.input + +export const v2SyncTriggerSlotSchema = z + .object({ + sourceWorkflowId: workflowIdSchema.describe('Workflow identifier in the source workspace.'), + sourceBlockId: z + .string() + .min(1) + .max(256) + .describe('Stable source trigger block identifier to use in trigger mappings.'), + blockName: z.string().max(1024).describe('Display name of the source trigger block.'), + workflowName: z.string().max(1024).describe('Display name of the source workflow.'), + ownPath: z + .string() + .max(4096) + .nullable() + .describe('Existing target trigger path, preserved automatically and not configurable.'), + adoptablePaths: z + .array(z.string().min(1).max(4096)) + .max(1000) + .describe('Retiring paths in the same target workflow with a compatible trigger provider.'), + defaultAdoptPath: z + .string() + .max(4096) + .nullable() + .describe('Default adoption when ownPath is null; null then allocates a new path.'), + }) + .strict() +export type V2SyncTriggerSlot = z.output + +export const v2ForkCopySelectionSchema = forkResourceSelectionSchema + .extend({ + files: forkResourceSelectionSchema.shape.files.describe('Workspace file IDs to copy.'), + tables: forkResourceSelectionSchema.shape.tables.describe( + 'Source table identifiers whose schemas and rows are copied.' + ), + knowledgeBases: forkResourceSelectionSchema.shape.knowledgeBases.describe( + 'Source knowledge base identifiers whose documents and content are copied.' + ), + customTools: forkResourceSelectionSchema.shape.customTools.describe( + 'Source custom tool identifiers to copy.' + ), + skills: forkResourceSelectionSchema.shape.skills.describe('Source skill identifiers to copy.'), + mcpServers: forkResourceSelectionSchema.shape.mcpServers.describe( + 'External MCP server identifiers to copy; OAuth connections require authorization in the destination.' + ), + workflowMcpServers: forkResourceSelectionSchema.shape.workflowMcpServers.describe( + 'Workflow-publishing MCP server identifiers to copy as empty configuration shells.' + ), + }) + .strict() +export const v2SyncCopySelectionSchema = promoteCopyResourcesSchema + .extend({ + files: promoteCopyResourcesSchema.shape.files.describe('Workspace file storage keys to copy.'), + tables: promoteCopyResourcesSchema.shape.tables.describe( + 'Source table identifiers whose schemas and rows are copied.' + ), + knowledgeBases: promoteCopyResourcesSchema.shape.knowledgeBases.describe( + 'Source knowledge base identifiers whose documents and content are copied.' + ), + customTools: promoteCopyResourcesSchema.shape.customTools.describe( + 'Source custom tool identifiers to copy.' + ), + skills: promoteCopyResourcesSchema.shape.skills.describe('Source skill identifiers to copy.'), + mcpServers: promoteCopyResourcesSchema.shape.mcpServers.describe( + 'External MCP server identifiers to copy; OAuth connections require authorization in the destination.' + ), + }) + .strict() +export const v2ForkPreviewBodySchema = z + .object({ + name: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe('Display name of the workflow or workspace.'), + copy: v2ForkCopySelectionSchema + .optional() + .describe( + 'Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.' + ), + }) + .strict() +export const v2ForkApplyBodySchema = v2ForkPreviewBodySchema + .extend({ + requestId: v2ForkRequestIdSchema.describe( + 'Stable client request ID for reconciliation and identical retries.' + ), + previewFingerprint: v2ForkFingerprintSchema.describe( + 'Fingerprint of the reviewed preview and its choices.' + ), + }) + .strict() +export const v2SyncPreviewBodySchema = z + .object({ + otherWorkspaceId: workspaceIdSchema.describe( + 'Workspace on the other side of the direct fork edge.' + ), + mappings: z + .array(v2ForkMappingSchema) + .max(5000) + .optional() + .describe('Proposed mappings; preview does not save them. Apply commits them with the sync.') + .describe('Mappings keyed by resource type and source identifier.'), + dependentValues: z + .array(v2ForkDependentValueSchema) + .max(2000) + .optional() + .describe('Destination choices addressed by source workflow, block, and field identities.') + .describe( + 'Destination-dependent choices keyed by source workflow, block, and field identities.' + ), + copyResources: v2SyncCopySelectionSchema + .optional() + .describe('Explicit source resources to copy before syncing the workflows.'), + dropReferences: z + .array( + z + .object({ + kind: forkRemapKindSchema.describe('Resource or operation kind.'), + sourceId: z + .string() + .min(1) + .max(4096) + .describe('Source resource identifier in the canonical source workspace.'), + }) + .strict() + ) + .max(2000) + .optional() + .describe( + 'Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.' + ), + triggerMappings: z + .array(v2SyncTriggerMappingSchema) + .max(500) + .optional() + .describe( + 'Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.' + ), + }) + .strict() +export const v2SyncApplyBodySchema = v2SyncPreviewBodySchema + .extend({ + requestId: v2ForkRequestIdSchema.describe( + 'Stable client request ID for reconciliation and identical retries.' + ), + previewFingerprint: v2ForkFingerprintSchema.describe( + 'Fingerprint of the reviewed preview and its choices.' + ), + confirm: z + .literal(true) + .describe( + 'Acknowledge replacement of target workflows and archival of mapped targets whose sources were deleted.' + ) + .describe('Explicit acknowledgement that sync replaces target workflows.'), + }) + .strict() +export const v2ForkPreviewDataSchema = z + .object({ + previewFingerprint: v2ForkFingerprintSchema.describe( + 'Fingerprint of the reviewed preview and its choices.' + ), + sourceWorkspaceId: workspaceIdSchema.describe( + 'Canonical workspace the workflows and resources are copied from.' + ), + workflows: z + .array( + z.object({ + sourceWorkflowId: workflowIdSchema.describe( + 'Workflow identifier in the source workspace.' + ), + name: z.string().max(1024).describe('Display name of the workflow or workspace.'), + }) + ) + .max(1000) + .describe('Eligible workflows and their planned actions.'), + selectedResourceCount: z + .number() + .int() + .min(0) + .describe('Number of resources explicitly selected for copying.'), + draftOnly: z.literal(true).describe('True because fork creation produces undeployed drafts.'), + }) + .meta({ + id: 'WorkspaceForkPreview', + title: 'WorkspaceForkPreview', + description: 'The WorkspaceForkPreview result.', + }) +export const v2SyncConfigurationSchema = z.object({ + sourceWorkflowId: workflowIdSchema.describe('Workflow identifier in the source workspace.'), + sourceBlockId: z.string().max(256).describe('Block identifier in the source workflow.'), + subBlockKey: z + .string() + .max(1024) + .describe('Registered source field key, including the tool index for nested Agent fields.'), + title: z.string().max(1024).describe('Human-readable configuration field label.'), + required: z + .boolean() + .describe('Whether the reference or configuration is required for this operation.'), + currentValue: z + .string() + .max(65536) + .describe('Persisted sync override or proposed override; empty when neither is configured.'), + multiSelect: z + .boolean() + .optional() + .describe('Whether the field accepts comma-separated selections.'), + selectorKey: z + .string() + .max(256) + .optional() + .describe('Registered selector key for discovering this field’s options.'), + discoveryWorkspaceId: workspaceIdSchema.describe( + 'Workspace scope for selector discovery: source for a parent being copied, otherwise destination.' + ), + context: z + .record(z.string().max(256), z.string().max(4096)) + .describe('Allowlisted selector dependencies scoped to discoveryWorkspaceId.'), + parentKind: forkRemapKindSchema.describe('Resource kind that owns this dependent configuration.'), + parentSourceId: z + .string() + .max(4096) + .describe('Source identifier of the parent resource being mapped.'), + parentContextKey: z + .string() + .max(256) + .optional() + .describe('Selector context key supplied by the mapped parent resource.'), +}) +export const v2SyncPreviewDataSchema = z + .object({ + previewFingerprint: v2ForkFingerprintSchema.describe( + 'Fingerprint of the reviewed preview and its choices.' + ), + sourceWorkspaceId: workspaceIdSchema.describe( + 'Canonical workspace the workflows and resources are copied from.' + ), + targetWorkspaceId: workspaceIdSchema.describe('Canonical workspace receiving the changes.'), + ready: z + .boolean() + .describe('Whether the operation passes its current apply or deployment readiness checks.'), + workflows: z + .array( + z.object({ + action: z + .enum(['create', 'replace', 'archive']) + .describe('Planned workflow creation, replacement, or archival.'), + sourceWorkflowId: workflowIdSchema + .optional() + .describe('Workflow identifier in the source workspace.'), + targetWorkflowId: workflowIdSchema + .optional() + .describe( + 'Existing target workflow identifier; absent when apply will create a new target.' + ), + name: z.string().max(1024).describe('Display name of the workflow or workspace.'), + }) + ) + .max(2000) + .describe('Eligible workflows and their planned actions.'), + unresolvedBindings: z + .array( + z.object({ + kind: z.string().max(256).describe('Resource or operation kind.'), + sourceId: z + .string() + .max(4096) + .describe('Source resource identifier in the canonical source workspace.'), + blockName: z + .string() + .max(1024) + .optional() + .describe('Display name of the affected source block.'), + reason: z + .string() + .max(256) + .optional() + .describe('Structured explanation of the unresolved binding.'), + }) + ) + .max(10000) + .describe( + 'Source references that still require destination mappings or explicit copy choices.' + ), + configuration: z + .array(v2SyncConfigurationSchema) + .max(10000) + .describe('Dependent fields that may need destination-specific values.'), + excludedTargets: z + .array( + z.object({ + id: workflowIdSchema.describe('Resource identifier.'), + name: z.string().max(1024).describe('Display name of the workflow or workspace.'), + }) + ) + .max(1000) + .describe('Target workflows explicitly excluded from sync.'), + triggerSlots: z + .array(v2SyncTriggerSlotSchema) + .max(10000) + .describe('Source triggers and the target paths available for explicit adoption choices.'), + triggerUrlChanges: z + .array( + z.object({ + workflowName: z.string().max(1024).describe('Name of the affected workflow.'), + path: z + .string() + .max(4096) + .describe('Public trigger path that stops serving after this sync.'), + }) + ) + .max(1000) + .describe('Retiring target trigger URLs no arriving trigger adopts.'), + }) + .meta({ + id: 'WorkspaceSyncPreview', + title: 'WorkspaceSyncPreview', + description: 'The WorkspaceSyncPreview result.', + }) +export const v2ForkOtherBodySchema = z + .object({ + otherWorkspaceId: workspaceIdSchema.describe( + 'Workspace on the other side of the direct fork edge.' + ), + }) + .strict() +export const v2ForkMappingQuerySchema = z + .object({ + otherWorkspaceId: workspaceIdSchema.describe( + 'Workspace on the other side of the direct fork edge.' + ), + direction: z + .enum(['push', 'pull']) + .describe( + 'Push means current to other; pull means other to current, independent of parent/child orientation.' + ), + ...v2PaginationFields(), + sortBy: z.enum(['id']).default('id').describe('Supported stable sort key for this collection.'), + sortOrder: z.enum(['asc']).default('asc').describe('Sort direction.'), + }) + .strict() +export const v2ForkMappingUpdateBodySchema = z + .object({ + otherWorkspaceId: workspaceIdSchema.describe( + 'Workspace on the other side of the direct fork edge.' + ), + direction: z + .enum(['push', 'pull']) + .describe( + 'Push means current to other; pull means other to current, independent of parent/child orientation.' + ), + mappings: z + .array(v2ForkMappingSchema) + .max(5000) + .describe('Mappings keyed by resource type and source identifier.'), + }) + .strict() +export const v2ForkChildrenQuerySchema = z + .object({ + ...v2PaginationFields(), + sortBy: z + .enum(['createdAt']) + .default('createdAt') + .describe('Supported stable sort key for this collection.'), + sortOrder: z.enum(['desc']).default('desc').describe('Sort direction.'), + }) + .strict() +export const v2ForkResourcesQuerySchema = z + .object({ + ...v2PaginationFields(), + kind: z + .enum([ + 'files', + 'tables', + 'knowledgeBases', + 'customTools', + 'skills', + 'mcpServers', + 'workflowMcpServers', + ]) + .describe('Resource or operation kind.'), + sortBy: z.enum(['id']).default('id').describe('Supported stable sort key for this collection.'), + sortOrder: z.enum(['asc']).default('asc').describe('Sort direction.'), + }) + .strict() +export const v2ForkExclusionsBodySchema = z + .object({ + workflowIds: z + .array(workflowIdSchema) + .min(1) + .max(1000) + .describe('Workflow identifiers in the current workspace.'), + forkSyncExcluded: z + .boolean() + .describe('Whether the named workflows should be skipped as sync sources and targets.'), + }) + .strict() +export const v2ForkLineageNodeSchema = z.object({ + id: workspaceIdSchema.describe('Resource identifier.'), + name: z.string().max(1024).describe('Display name of the workflow or workspace.'), + organizationId: z + .string() + .nullable() + .describe('Owning organization, or null for a personal workspace.'), +}) + +export const v2PreviewWorkspaceForkContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/preview', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkPreviewBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ForkPreviewDataSchema) }, +}) + +export const v2ForkWorkspaceContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkApplyBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2OperationReportSchema) }, +}) + +export const v2PreviewWorkspacePushContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/push/preview', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2SyncPreviewBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2SyncPreviewDataSchema) }, +}) + +export const v2PushWorkspaceContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/push', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2SyncApplyBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2OperationReportSchema) }, +}) + +export const v2PreviewWorkspacePullContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/pull/preview', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2SyncPreviewBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2SyncPreviewDataSchema) }, +}) + +export const v2PullWorkspaceContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/pull', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2SyncApplyBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2OperationReportSchema) }, +}) + +export const v2GetWorkspaceForkAvailabilityContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/availability', + params: v2ForkParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + available: z + .boolean() + .describe('Whether this deployment and workspace plan enable forking.'), + }) + .meta({ + id: 'GetWorkspaceForkAvailabilityResult', + title: 'GetWorkspaceForkAvailabilityResult', + description: 'The GetWorkspaceForkAvailabilityResult result.', + }) + ), + }, +}) + +export const v2GetWorkspaceForkLineageContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/lineage', + params: v2ForkParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + current: v2ForkLineageNodeSchema.describe('The current workspace lineage node.'), + parent: v2ForkLineageNodeSchema + .nullable() + .describe('The live parent workspace, or null when this workspace is not a fork.'), + }) + .meta({ + id: 'GetWorkspaceForkLineageResult', + title: 'GetWorkspaceForkLineageResult', + description: 'The GetWorkspaceForkLineageResult result.', + }) + ), + }, +}) + +export const v2ListWorkspaceForkChildrenContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/children', + params: v2ForkParamsSchema, + query: v2ForkChildrenQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse( + v2ForkLineageNodeSchema + .extend({ createdAt: z.iso.datetime().describe('ISO 8601 creation timestamp.') }) + .meta({ + id: 'ListWorkspaceForkChildrenResult', + title: 'ListWorkspaceForkChildrenResult', + description: 'The ListWorkspaceForkChildrenResult result.', + }) + ), + }, +}) + +export const v2ListWorkspaceForkResourcesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/resources', + params: v2ForkParamsSchema, + query: v2ForkResourcesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse( + z + .object({ + id: z.string().max(4096).describe('Resource identifier.'), + label: z.string().max(1024).describe('Human-readable resource label.'), + folderId: z + .string() + .nullable() + .optional() + .describe('Containing folder identifier, or null at the workspace root.'), + folderName: z + .string() + .nullable() + .optional() + .describe('Containing folder name, or null at the workspace root.'), + }) + .meta({ + id: 'ListWorkspaceForkResourcesResult', + title: 'ListWorkspaceForkResourcesResult', + description: 'The ListWorkspaceForkResourcesResult result.', + }) + ), + }, +}) + +export const v2GetWorkspaceForkMappingsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/mappings', + params: v2ForkParamsSchema, + query: v2ForkMappingQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse( + v2ForkMappingSchema + .extend({ id: z.string().max(256).describe('Resource identifier.') }) + .meta({ + id: 'GetWorkspaceForkMappingsResult', + title: 'GetWorkspaceForkMappingsResult', + description: 'The GetWorkspaceForkMappingsResult result.', + }) + ), + }, +}) + +export const v2UpdateWorkspaceForkMappingsContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/workspaces/[workspaceId]/fork/mappings', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkMappingUpdateBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ updated: z.number().int().min(0).describe('Number of records changed.') }).meta({ + id: 'UpdateWorkspaceForkMappingsResult', + title: 'UpdateWorkspaceForkMappingsResult', + description: 'The UpdateWorkspaceForkMappingsResult result.', + }) + ), + }, +}) + +export const v2RollbackWorkspaceForkContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/rollback', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkOtherBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + restored: z + .number() + .int() + .min(0) + .describe('Workflows restored to their prior deployed version.'), + archived: z + .number() + .int() + .min(0) + .describe('Workflows created by the sync and now archived.'), + unarchived: z + .number() + .int() + .min(0) + .describe('Previously archived workflows restored by rollback.'), + skipped: z + .number() + .int() + .min(0) + .describe('Snapshot workflows no longer available to restore.'), + pendingActivations: z + .array(z.string().max(256)) + .max(1000) + .describe('Workflows whose restored deployment is still activating.'), + }) + .meta({ + id: 'RollbackWorkspaceForkResult', + title: 'RollbackWorkspaceForkResult', + description: 'The RollbackWorkspaceForkResult result.', + }) + ), + }, +}) + +export const v2UnlinkWorkspaceForkContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/unlink', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkOtherBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ unlinked: z.boolean().describe('Whether the fork edge was removed.') }).meta({ + id: 'UnlinkWorkspaceForkResult', + title: 'UnlinkWorkspaceForkResult', + description: 'The UnlinkWorkspaceForkResult result.', + }) + ), + }, +}) + +export const v2UpdateWorkspaceForkExclusionsContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/workspaces/[workspaceId]/fork/exclusions', + params: v2ForkParamsSchema, + query: noInputSchema, + body: v2ForkExclusionsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse( + z.object({ updated: z.number().int().min(0).describe('Number of records changed.') }).meta({ + id: 'UpdateWorkspaceForkExclusionsResult', + title: 'UpdateWorkspaceForkExclusionsResult', + description: 'The UpdateWorkspaceForkExclusionsResult result.', + }) + ), + }, +}) +export type V2ForkPreviewBody = z.input +export type V2ForkApplyBody = z.input +export type V2SyncPreviewBody = z.input +export type V2SyncApplyBody = z.input +export type V2ForkOtherBody = z.input +export type V2ForkMappingQuery = z.input +export type V2ForkMappingUpdateBody = z.input +export type V2ForkChildrenQuery = z.input +export type V2ForkResourcesQuery = z.input +export type V2ForkExclusionsBody = z.input +export type V2ForkPreviewData = z.output +export type V2SyncPreviewData = z.output +export type V2SyncConfiguration = z.output diff --git a/apps/sim/lib/api/contracts/v2/workspace-operations.ts b/apps/sim/lib/api/contracts/v2/workspace-operations.ts new file mode 100644 index 00000000000..3753f18271e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/workspace-operations.ts @@ -0,0 +1,174 @@ +import { z } from 'zod' +import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, +} from '@/lib/api/contracts/v2/shared' +import { forkTriggerUrlChangeSchema } from '@/lib/api/contracts/workspace-fork' + +export const v2OperationIssueSchema = z.object({ + code: z.string().min(1).max(128).describe('Stable machine-readable issue code.'), + message: z.string().max(2048).describe('Human-readable explanation of the issue.'), + workflowId: z + .string() + .max(256) + .optional() + .describe('Workflow affected by this issue or deployment attempt.'), + blockId: z + .string() + .max(256) + .optional() + .describe('Source block identifier before graph ID regeneration.'), + subBlockKey: z + .string() + .max(256) + .optional() + .describe('Registered source field key, including the tool index for nested Agent fields.'), +}) +export const v2OperationReportSchema = z + .object({ + operationId: z + .string() + .min(1) + .max(256) + .describe('Durable operation identifier to use for polling.'), + requestId: z + .string() + .min(1) + .max(128) + .describe('Stable client request ID for reconciliation and identical retries.'), + workspaceId: workspaceIdSchema.describe('Explicit current workspace scope.'), + kind: z + .enum(['workflow_import', 'workspace_fork', 'workspace_push', 'workspace_pull']) + .describe('Resource or operation kind.'), + applied: z + .literal(true) + .describe('The business transaction committed, including when follow-up work fails.'), + status: z + .enum([ + 'processing', + 'completed', + 'completed_with_warnings', + 'requires_configuration', + 'failed', + ]) + .describe('Current operation or deployment outcome.'), + resourceIds: z + .array(z.string().min(1).max(256)) + .max(5000) + .describe('Identifiers of resources created or changed by the committed operation.'), + issues: z + .array(v2OperationIssueSchema) + .max(2000) + .describe('Structured warnings, missing configuration, and follow-up failures.'), + idMap: z + .record(z.string().max(256), z.string().max(256)) + .optional() + .describe('Source graph identifiers mapped to the imported identifiers.'), + deploymentOperationIds: z + .array(z.string().max(256)) + .max(1000) + .optional() + .describe('Exact deployment attempts admitted by the workspace operation.'), + deployments: z + .array( + z.object({ + operationId: z + .string() + .max(256) + .describe('Durable operation identifier to use for polling.'), + workflowId: z + .string() + .max(256) + .describe('Workflow affected by this issue or deployment attempt.'), + version: z + .number() + .int() + .min(1) + .describe('Reference format or deployment version number.'), + status: z + .enum(['preparing', 'activating', 'active', 'failed', 'superseded']) + .describe('Current operation or deployment outcome.'), + ready: z + .boolean() + .describe( + 'Whether the operation passes its current apply or deployment readiness checks.' + ), + pendingComponents: z + .array(z.string().max(128)) + .max(32) + .describe('Deployment components that have not finished becoming ready.'), + }) + ) + .max(1000) + .optional() + .describe('Readiness of the exact admitted deployment attempts.'), + triggerUrlChanges: z + .array(forkTriggerUrlChangeSchema) + .max(1000) + .optional() + .describe('Public trigger paths changed by this sync, with the affected workflow names.'), + backgroundWorkId: z + .string() + .max(256) + .optional() + .describe('Workspace activity identifier for resource-copy progress.'), + copyProgress: z + .object({ + status: z + .enum(['pending', 'completed', 'failed']) + .describe('Current operation or deployment outcome.'), + copied: z.number().int().min(0).describe('Number of resources copied successfully.'), + failed: z.number().int().min(0).describe('Number of resources that failed to copy.'), + }) + .optional() + .describe('Completion status and counts for explicitly selected resource copies.'), + }) + .meta({ + id: 'WorkspaceOperationReport', + title: 'WorkspaceOperationReport', + description: 'The WorkspaceOperationReport result.', + }) +export const v2WorkspaceOperationParamsSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Explicit current workspace scope.'), + operationId: z + .string() + .min(1) + .max(256) + .describe('Durable operation identifier to use for polling.'), + }) + .strict() +export const v2ListWorkspaceOperationsQuerySchema = z + .object({ + ...v2PaginationFields(), + requestId: z + .string() + .min(1) + .max(128) + .optional() + .describe('Find an uncertain mutation by its original request ID.') + .describe('Stable client request ID for reconciliation and identical retries.'), + }) + .strict() +export const v2GetWorkspaceOperationContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/operations/[operationId]', + params: v2WorkspaceOperationParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2OperationReportSchema) }, +}) +export const v2ListWorkspaceOperationsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/operations', + params: z + .object({ workspaceId: workspaceIdSchema.describe('Explicit current workspace scope.') }) + .strict(), + query: v2ListWorkspaceOperationsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2OperationReportSchema) }, +}) +export type V2OperationReport = z.output +export type V2ListWorkspaceOperationsQuery = z.input +export type V2WorkspaceOperationParams = z.input diff --git a/apps/sim/lib/api/contracts/workflow-references.ts b/apps/sim/lib/api/contracts/workflow-references.ts new file mode 100644 index 00000000000..c1ac6498034 --- /dev/null +++ b/apps/sim/lib/api/contracts/workflow-references.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' +import { WORKFLOW_RESOURCE_KINDS } from '@/lib/workflows/references/types' + +export const portableResourceKindSchema = z.enum([...WORKFLOW_RESOURCE_KINDS, 'workflow']) +export const referenceOccurrenceSchema = z + .object({ + blockId: z + .string() + .min(1) + .max(256) + .refine( + (key) => !['__proto__', 'prototype', 'constructor'].includes(key), + 'Invalid reference block' + ) + .describe('Source block identifier before graph ID regeneration.'), + subBlockKey: z + .string() + .min(1) + .max(256) + .refine( + (key) => !['__proto__', 'prototype', 'constructor'].includes(key), + 'Invalid reference field' + ) + .describe('Registered source field key, including the tool index for nested Agent fields.'), + valuePath: z + .array( + z.union([ + z + .string() + .min(1) + .max(256) + .refine( + (key) => !['__proto__', 'prototype', 'constructor'].includes(key), + 'Invalid reference path' + ), + z.number().int().min(0).max(2000), + ]) + ) + .max(8) + .describe( + 'Path within the field value; strings address properties and numbers address array entries.' + ), + positions: z + .array(z.number().int().min(0).max(2000)) + .max(2000) + .optional() + .describe('Positions occupied by this identifier in a multi-value field.'), + encoding: z + .enum(['scalar', 'array', 'csv', 'files', 'environment']) + .describe('Registered encoding used to discover and rewrite the reference.'), + }) + .strict() +export const portableReferenceSchema = z + .object({ + kind: portableResourceKindSchema.describe('Resource or operation kind.'), + sourceId: z + .string() + .min(1) + .max(4096) + .describe( + 'Untrusted source reference label; imports never use it to authorize or query a source workspace.' + ), + required: z + .boolean() + .describe('Whether the reference or configuration is required for this operation.'), + occurrences: z + .array(referenceOccurrenceSchema) + .min(1) + .max(10000) + .describe('Every registered source block and field occurrence of this reference.'), + }) + .strict() +export const workflowReferenceManifestSchema = z + .object({ + version: z.literal(1).describe('Reference format or deployment version number.'), + references: z + .array(portableReferenceSchema) + .max(10000) + .describe('Non-secret resource identifiers and their registered occurrences.'), + }) + .strict() + .superRefine((manifest, ctx) => { + if (manifest.references.reduce((sum, entry) => sum + entry.occurrences.length, 0) > 10000) { + ctx.addIssue({ + code: 'custom', + message: 'Reference manifest exceeds 10000 field occurrences', + }) + } + }) +export type WorkflowReferenceManifestBody = z.input diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 8238e7dcc28..b9afda09bc2 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -2,26 +2,11 @@ import { z } from 'zod' import { nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { workspaceSchema } from '@/lib/api/contracts/workspaces' +import { WORKFLOW_RESOURCE_KINDS } from '@/lib/workflows/references/types' const workspaceIdParamsSchema = z.object({ id: nonEmptyIdSchema }) -export const forkRemapKindSchema = z.enum([ - 'credential', - 'env-var', - 'knowledge-base', - 'knowledge-document', - 'table', - 'file', - 'file-folder', - 'mcp-server', - 'custom-tool', - /** - * A published custom block, referenced by the placed block's `type` rather than by any - * sub-block value — the only remap kind that rewrites the block itself. - */ - 'custom-block', - 'skill', -]) +export const forkRemapKindSchema = z.enum(WORKFLOW_RESOURCE_KINDS) export const forkResourceTypeSchema = z.enum([ 'workflow', @@ -48,6 +33,7 @@ export const forkResourceTypeSchema = z.enum([ 'custom_block', 'custom_tool', 'skill', + 'sandbox', ]) /** @@ -164,6 +150,7 @@ export const forkWorkspaceContract = defineRouteContract({ body: forkWorkspaceBodySchema, response: { mode: 'json', + status: 201, schema: z.object({ // Full workspace row so the client can merge it into the workspace-list cache // (parity with create), not just the lineage node. @@ -347,7 +334,7 @@ export const forkDependentReconfigSchema = z.object({ * block makes EVERY one of its inputs reconfigurable, not the `dependsOn` subset a * credential/KB/table swap invalidates. */ - parentKind: z.enum(['credential', 'knowledge-base', 'table', 'custom-block']), + parentKind: z.enum(['credential', 'knowledge-base', 'table', 'custom-block', 'mcp-server']), /** Source id of that parent (matches a mapping entry's `sourceId`). */ parentSourceId: z.string(), /** @@ -362,6 +349,7 @@ export const forkDependentReconfigSchema = z.object({ subBlockKey: z.string(), /** Absent for `custom-block` fields, which are typed inputs rather than selectors. */ selectorKey: z.string().optional(), + multiSelect: z.boolean().optional(), /** * A `custom-block` input's declared field type (`string` | `number` | `boolean` | `object` | * `array` | ...), so the modal renders the matching control instead of a selector. @@ -540,9 +528,12 @@ export const getForkDiffQuerySchema = z.object({ * subscription - has to be repointed by hand afterwards. */ export const forkTriggerUrlChangeSchema = z.object({ - workflowName: z.string(), + workflowName: z + .string() + .max(1024) + .describe('Name of the workflow whose public trigger path stops serving.'), /** The path that stops being served. A URL an arriving trigger adopts is not reported here. */ - path: z.string(), + path: z.string().max(4096).describe('Public trigger path that stops serving after this sync.'), }) export type ForkTriggerUrlChange = z.output @@ -741,6 +732,7 @@ export const promoteForkContract = defineRouteContract({ archived: z.number().int(), redeployed: z.number().int(), deployFailed: z.number().int(), + deployWarnings: z.array(z.string().max(2048)).max(1000).default([]), unmappedRequired: z.array(forkUnmappedReferenceSchema), /** * References the sync would have cleared, so it was blocked without writing (the diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 0d8f03d9ec8..8e3e5c643a3 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -44,11 +44,13 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/sandboxes.mdx', 'cli/scripting.mdx', 'cli/secrets.mdx', + 'cli/selectors.mdx', 'cli/skills.mdx', 'cli/tables.mdx', 'cli/tools.mdx', 'cli/troubleshooting.mdx', 'cli/workflow-mcp-servers.mdx', + 'cli/workflow-sync.mdx', 'cli/workflows.mdx', 'cli/workspaces.mdx', 'desktop.mdx', diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index 14c4aba6102..c3a8b2e1d3d 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -16,6 +16,8 @@ import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types import type { ResourcePolicyBinding } from '@/lib/resource-policies/registry' export interface WorkspaceUseCaseAuditEntry { + /** Canonical workspace affected by a cross-workspace mutation, when different from its authorization scope. */ + workspaceId?: string action: AuditActionType resourceType: AuditResourceTypeValue resourceId?: string @@ -103,7 +105,7 @@ export function recordProjectedUseCaseAuditEntries( const attribution: PrincipalAuditAttribution = resolvePrincipalAuditAttribution(principal) for (const entry of entries) { recordAudit({ - workspaceId, + workspaceId: entry.workspaceId ?? workspaceId, actorId: attribution.actorId, actorName: attribution.actorName, action: entry.action, diff --git a/apps/sim/lib/core/orchestration/types.ts b/apps/sim/lib/core/orchestration/types.ts index dabdcfb23bc..b9ed678fd5a 100644 --- a/apps/sim/lib/core/orchestration/types.ts +++ b/apps/sim/lib/core/orchestration/types.ts @@ -112,4 +112,5 @@ export function asOrchestrationError(error: unknown): OrchestrationError | null /** Transport metadata available to an application operation for audit capture. */ export interface OrchestrationRequestContext { headers: { get(name: string): string | null } + signal?: AbortSignal } diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 66fb7b62de0..6fb0b627643 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -645,28 +645,28 @@ export async function removeMcpToolsForWorkflow( * Publish pubsub events for each unique server affected by a tool change. * Resolves workspace IDs from the server table so callers don't need to pass them. */ -export function notifyMcpToolServers(tools: Array<{ serverId: string }>): void { - if (!mcpPubSub) return - - const uniqueServerIds = [...new Set(tools.map((t) => t.serverId))] - - void (async () => { - try { - const servers = await db - .select({ id: workflowMcpServer.id, workspaceId: workflowMcpServer.workspaceId }) - .from(workflowMcpServer) - .where( - and(inArray(workflowMcpServer.id, uniqueServerIds), isNull(workflowMcpServer.deletedAt)) - ) +/** Publishes affected servers synchronously so durable callers can observe failures. */ +export async function publishMcpToolServerChanges(serverIds: string[]): Promise { + if (!mcpPubSub || !serverIds.length) return + const servers = await db + .select({ id: workflowMcpServer.id, workspaceId: workflowMcpServer.workspaceId }) + .from(workflowMcpServer) + .where( + and( + inArray(workflowMcpServer.id, [...new Set(serverIds)]), + isNull(workflowMcpServer.deletedAt) + ) + ) + for (const server of servers) { + await mcpPubSub.publishWorkflowToolsChanged({ + serverId: server.id, + workspaceId: server.workspaceId, + }) + } +} - for (const server of servers) { - mcpPubSub.publishWorkflowToolsChanged({ - serverId: server.id, - workspaceId: server.workspaceId, - }) - } - } catch (error) { - logger.error('Error notifying affected servers:', error) - } - })() +export function notifyMcpToolServers(tools: Array<{ serverId: string }>): void { + void publishMcpToolServerChanges(tools.map((tool) => tool.serverId)).catch((error) => { + logger.error('Error notifying affected servers:', error) + }) } diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts index 3bbcfc93b6b..1a310870d2c 100644 --- a/apps/sim/lib/secrets/references/scan.ts +++ b/apps/sim/lib/secrets/references/scan.ts @@ -3,9 +3,9 @@ import { customTools, mcpServers, workflow, workflowBlocks } from '@sim/db/schem import { createLogger } from '@sim/logger' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import { ENV_REF_PATTERN, remapSubBlocks } from '@/lib/workflows/references/remap-references' import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' -import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/remap/remap-references' const logger = createLogger('SecretReferenceScan') diff --git a/apps/sim/lib/selectors/api/error-policy.ts b/apps/sim/lib/selectors/api/error-policy.ts new file mode 100644 index 00000000000..25a71fa2159 --- /dev/null +++ b/apps/sim/lib/selectors/api/error-policy.ts @@ -0,0 +1,31 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' + +export const v2SelectorErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Selector scope not found', + render(error) { + if (error instanceof SelectorContextUnavailableError) + return v2Error('BAD_REQUEST', 'Context unavailable') + if (error instanceof SelectorConnectionUnavailableError) + return v2Error( + 'FORBIDDEN', + 'Connection unavailable. Connect or authorize an accessible OAuth connection before continuing.', + { + details: { reason: 'connection_required', humanAuthorizationMayBeRequired: true }, + } + ) + if (error instanceof IntegrationNotAllowedError) return v2Error('FORBIDDEN', error.message) + if (error instanceof SelectorOptionsUnavailableError) + return v2Error( + error.status === 429 ? 'RATE_LIMITED' : 'INTERNAL_ERROR', + 'Selector options unavailable' + ) + return v2CaughtOrchestrationError(error) + }, +}) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 73eebf344f0..1f70f08cc9f 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -7,6 +7,7 @@ import { import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import type { OperationUseCase } from '@/lib/core/application/operation' import { requireOrganizationMembership } from '@/lib/core/application/organization-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { type CredentialAuditRequest, recordCredentialAccess } from '@/lib/oauth/token-resolution' import { selectorOperations } from '@/lib/selectors/application/operations' import { @@ -29,7 +30,7 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected- import { resolveSelectorReferences } from '@/lib/selectors/server/references' import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' -import type { ResolvedSelectorReference } from '@/lib/selectors/server/types' +import type { ResolvedSelectorReference, SelectorPrincipal } from '@/lib/selectors/server/types' import type { SelectorExecutionResult, SelectorRequest } from '@/lib/selectors/types' import { IntegrationNotAllowedError } from '@/ee/access-control/utils/permission-check' @@ -124,7 +125,7 @@ function getReferencedDetailResolvedId(input: { } async function executeAuthorizedSelector(args: { - principal: { kind: 'session'; userId: string; sessionId: string } + principal: SelectorPrincipal input: ExecuteSelectorInput context: SelectorApplicationContext }): Promise { @@ -280,7 +281,8 @@ async function executeAuthorizedSelector(args: { error instanceof SelectorOptionsUnavailableError || // A refusal, not a provider failure: it reaches the caller as its own 403 // rather than being folded into "Options unavailable". - error instanceof IntegrationNotAllowedError + error instanceof IntegrationNotAllowedError || + error instanceof OrchestrationError ) { throw error } @@ -327,6 +329,14 @@ export const executeSelector: OperationUseCase< > = { operation: selectorOperations.execute, async execute(args) { + args = { + ...args, + input: { + ...args.input, + signal: args.input.signal ?? args.request?.signal, + auditRequest: args.input.auditRequest ?? args.request, + }, + } if (args.input.scope.kind !== 'organization') return executeWorkspaceSelector.execute(args) if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() await requireOrganizationMembership( diff --git a/apps/sim/lib/selectors/application/get-selector-option.ts b/apps/sim/lib/selectors/application/get-selector-option.ts new file mode 100644 index 00000000000..d9bea516d1b --- /dev/null +++ b/apps/sim/lib/selectors/application/get-selector-option.ts @@ -0,0 +1,61 @@ +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ExecuteSelectorInput, + executeSelector, +} from '@/lib/selectors/application/execute-selector' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { getSelectorManifestEntry } from '@/lib/selectors/manifest' +import type { SafeSelectorOption } from '@/lib/selectors/types' + +type GetSelectorOptionInput = Omit & { id: string } + +/** Resolves a choice through authorized execution, including providers with list-only APIs. */ +export const getSelectorOption: OperationUseCase< + typeof selectorOperations.execute, + GetSelectorOptionInput, + SafeSelectorOption | null +> = { + operation: selectorOperations.execute, + async execute({ input, ...args }) { + const { id, ...base } = input + if (getSelectorManifestEntry(input.selectorKey).supportsDetail) { + const result = await executeSelector.execute({ + ...args, + input: { ...base, request: { kind: 'detail', id } }, + }) + if (result.kind !== 'detail') + throw new OrchestrationError('internal', 'Selector returned an invalid detail') + return result.item + } + let cursor: string | undefined + const visited = new Set() + let total = 0 + for (let page = 0; page < 100; page++) { + const result = await executeSelector.execute({ + ...args, + input: { ...base, request: { kind: 'list', cursor } }, + }) + if (result.kind !== 'list') + throw new OrchestrationError('internal', 'Selector returned an invalid page') + const item = result.items.find((option) => option.id === id) + if (item) return item + total += result.items.length + if (!result.nextCursor) { + if (result.truncated) + throw new OrchestrationError( + 'payload_too_large', + 'Selector results are truncated; this choice could not be verified' + ) + return null + } + if (total >= 10_000 || visited.has(result.nextCursor)) break + cursor = result.nextCursor + visited.add(cursor) + } + throw new OrchestrationError( + 'payload_too_large', + 'Selector verification exceeded its pagination limit' + ) + }, +} diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts index ca37e5a19e1..02aa3d610e2 100644 --- a/apps/sim/lib/selectors/application/operations.ts +++ b/apps/sim/lib/selectors/application/operations.ts @@ -6,7 +6,8 @@ export const selectorOperations = { id: 'selectors.execute', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + oauthScope: 'api:read', capability: 'none', }), } as const diff --git a/apps/sim/lib/selectors/application/paged-selector.ts b/apps/sim/lib/selectors/application/paged-selector.ts new file mode 100644 index 00000000000..0465036b361 --- /dev/null +++ b/apps/sim/lib/selectors/application/paged-selector.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { executeSelector } from '@/lib/selectors/application/execute-selector' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import type { SafeSelectorOption, SelectorContext } from '@/lib/selectors/types' +import { workflowOperationFingerprint } from '@/lib/workspaces/operations/receipts' + +export interface ListSelectorInput { + workspaceId: string + selectorKey: ServerSelectorKey + context: SelectorContext + search?: string + cursor?: string + limit: number + signal?: AbortSignal +} + +export interface SelectorPage { + items: SafeSelectorOption[] + nextCursor: string | null + truncated: boolean +} + +const cursorSchema = z + .object({ + version: z.literal(1), + providerCursor: z + .string() + .max(16 * 1024) + .optional(), + offset: z.number().int().min(0).max(MAX_SELECTOR_OPTIONS), + pageHash: z.string().length(64).optional(), + scopeHash: z.string().length(64), + }) + .strict() + +/** Pages a bounded provider page without retaining credential context or provider results. */ +export const listSelector: OperationUseCase< + typeof selectorOperations.execute, + ListSelectorInput, + SelectorPage +> = { + operation: selectorOperations.execute, + async execute({ principal, input, ...rest }) { + const scopeHash = workflowOperationFingerprint({ + workspaceId: input.workspaceId, + selectorKey: input.selectorKey, + context: input.context, + search: input.search, + }) + let cursor: z.output = { version: 1, offset: 0, scopeHash } + if (input.cursor) { + try { + cursor = cursorSchema.parse( + JSON.parse(Buffer.from(input.cursor, 'base64url').toString('utf8')) + ) + } catch { + throw new OrchestrationError('validation', 'Invalid selector cursor') + } + if (cursor.scopeHash !== scopeHash) + throw new OrchestrationError( + 'validation', + 'Selector cursor does not match the requested scope or dependencies' + ) + } + const result = await executeSelector.execute({ + ...rest, + principal, + input: { + selectorKey: input.selectorKey, + context: input.context, + scope: { kind: 'workspace', workspaceId: input.workspaceId }, + request: { kind: 'list', search: input.search, cursor: cursor.providerCursor }, + signal: input.signal, + }, + }) + if (result.kind !== 'list') + throw new OrchestrationError('internal', 'Selector returned an invalid page') + const serialized = JSON.stringify(result.items) + if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024 * 1024) + throw new OrchestrationError( + 'payload_too_large', + 'Selector page exceeds 8 MiB; narrow the search' + ) + const pageHash = createHash('sha256').update(serialized).digest('hex') + if (cursor.pageHash && cursor.pageHash !== pageHash) { + throw new OrchestrationError( + 'conflict', + 'Selector options changed; restart discovery without a cursor' + ) + } + const end = cursor.offset + input.limit + const next = + end < result.items.length + ? { version: 1, providerCursor: cursor.providerCursor, offset: end, pageHash, scopeHash } + : result.nextCursor + ? { version: 1, providerCursor: result.nextCursor, offset: 0, scopeHash } + : null + return { + items: result.items.slice(cursor.offset, end), + nextCursor: next ? Buffer.from(JSON.stringify(next)).toString('base64url') : null, + truncated: result.truncated ?? false, + } + }, +} diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 599b78e1866..76d9c47e0ff 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -9,8 +9,8 @@ describe('selector manifest', () => { const count = (classification: (typeof classifications)[number]) => classifications.filter((value) => value === classification).length - expect(Object.keys(selectorManifest)).toHaveLength(95) - expect(count('provider-server')).toBe(82) + expect(Object.keys(selectorManifest)).toHaveLength(96) + expect(count('provider-server')).toBe(83) expect(count('internal-server')).toBe(12) expect(count('local')).toBe(1) expect(classifications).not.toContain('provider-legacy') @@ -36,11 +36,12 @@ describe('selector manifest', () => { const rawConnectionKeys = providerKeys.filter( (key) => !serverSelectorRegistry[key as keyof typeof serverSelectorRegistry].credential ) - expect(providerKeys).toHaveLength(82) + expect(providerKeys).toHaveLength(83) expect(rawConnectionKeys.sort()).toEqual([ 'cloudwatch.logGroups', 'cloudwatch.logStreams', 'imap.mailboxes', + 'mcp.tools', ]) }) @@ -98,7 +99,7 @@ describe('selector manifest', () => { (attachment) => attachment.destination !== 'fixed' ) - expect(preparedDestinations).toHaveLength(13) + expect(preparedDestinations).toHaveLength(14) for (const attachment of preparedDestinations) { expect(attachment.destination).toEqual( expect.objectContaining({ diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index fbafd6df703..418e0da162d 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -346,6 +346,14 @@ export const selectorManifest = { readiness: { all: ['host', 'username', 'password'] }, sensitive: ['username', 'password'], }), + 'mcp.tools': rawProviderSelector(['mcpServerId'], { + readiness: { all: ['mcpServerId'] }, + sourceFields: { mcpServerId: ['serverId', 'server'] }, + listMode: 'paginated', + search: true, + detail: true, + staleTime: 0, + }), 'managedAgent.agents': providerSelector(), 'managedAgent.environments': providerSelector(['environmentType']), 'managedAgent.vaults': providerSelector(), diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 9acb048fd7d..8e44819d55f 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -1,4 +1,3 @@ -import type { SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { account, credential } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' @@ -6,7 +5,6 @@ import { authorizeCredentialUseForAuth, type CredentialAccessResult, } from '@/lib/auth/credential-access' -import { AuthType } from '@/lib/auth/hybrid' import { authorizeOrganizationCredentialUse, resolveOrganizationCredentialTokenBundle, @@ -18,6 +16,7 @@ import type { AuthorizedSelectorCredential, ResolvedSelectorReference, SelectorCredentialPolicy, + SelectorPrincipal, SelectorProtectedValues, } from '@/lib/selectors/server/types' import type { SelectorContext, SelectorScope } from '@/lib/selectors/types' @@ -100,7 +99,7 @@ async function requireCredentialProviderBinding( } export async function authorizeSelectorCredential(input: { - principal: SessionPrincipal + principal: SelectorPrincipal context: SelectorContext scope: SelectorScope workspaceId?: string @@ -113,7 +112,11 @@ export async function authorizeSelectorCredential(input: { if (!suppliedId) throw new SelectorConnectionUnavailableError() if (input.scope.kind === 'organization') { - if (input.workspaceId || input.organizationId !== input.scope.organizationId) + if ( + input.principal.kind !== 'session' || + input.workspaceId || + input.organizationId !== input.scope.organizationId + ) throw new SelectorConnectionUnavailableError() const { credential: row } = await authorizeOrganizationCredentialUse({ principal: input.principal, @@ -152,7 +155,6 @@ export async function authorizeSelectorCredential(input: { { success: true, userId: input.principal.userId, - authType: AuthType.SESSION, }, { credentialId: suppliedId, diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index c7f7fde32c9..446d1bdf236 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -1,14 +1,18 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' import { listInternalCredentials } from '@/lib/credentials/application/credential-crud' import { fetchOllamaEmbeddingModelCatalog } from '@/lib/embeddings/ollama-model-catalog.server' import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server' import { getEffectiveEnvironmentVariableNames } from '@/lib/environment/utils' -import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { listKnowledgeDocuments, readKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { + getWorkspaceSandboxUseCase, + listWorkspaceSandboxesUseCase, +} from '@/lib/sandboxes/application/use-cases' import type { InternalSelectorKey } from '@/lib/selectors/manifest' import { SelectorContextUnavailableError, @@ -182,6 +186,7 @@ export const internalSelectorAttachments = { 'workspace.credentialProviders': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { + if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() if (!args.workspaceId) throw new SelectorContextUnavailableError() const result = await listInternalCredentials.execute({ principal: args.principal, @@ -212,6 +217,7 @@ export const internalSelectorAttachments = { 'workspace.credentialGroupProviders': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { + if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() if (!args.workspaceId) throw new SelectorContextUnavailableError() const result = await getWorkspaceOrganizationAccounts.execute({ principal: args.principal, @@ -229,6 +235,7 @@ export const internalSelectorAttachments = { 'workspace.organizationMcpProviders': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { + if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() if (!args.workspaceId) throw new SelectorContextUnavailableError() const result = await getWorkspaceOrganizationAccounts.execute({ principal: args.principal, @@ -257,6 +264,7 @@ export const internalSelectorAttachments = { 'workspace.rawSecretNames': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { + if (args.principal.kind !== 'session') throw new SelectorContextUnavailableError() if (!args.workspaceId) throw new SelectorContextUnavailableError() const result = await listInternalCredentials.execute({ principal: args.principal, @@ -279,12 +287,19 @@ export const internalSelectorAttachments = { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { if (!args.workspaceId) throw new SelectorContextUnavailableError() - const sandboxes = await listWorkspaceSandboxes(args.workspaceId) const language = args.context.language if (args.request.kind === 'detail') { - const detailId = args.request.id - const sandbox = sandboxes.find((candidate) => candidate.id === detailId) - if (!sandbox) return detailSelectorResult(null) + const result = await getWorkspaceSandboxUseCase + .execute({ + principal: args.principal, + input: { workspaceId: args.workspaceId, sandboxId: args.request.id }, + }) + .catch((error: unknown) => { + if (error instanceof OrchestrationError && error.code === 'not_found') return null + throw error + }) + if (!result) return detailSelectorResult(null) + const { sandbox } = result const wrongLanguage = (language === 'python' || language === 'javascript') && sandbox.language !== language return detailSelectorResult({ @@ -292,6 +307,11 @@ export const internalSelectorAttachments = { label: wrongLanguage ? `${sandbox.name} · wrong language for this block` : sandbox.name, }) } + const { sandboxes, nextCursorKeys } = await listWorkspaceSandboxesUseCase.execute({ + principal: args.principal, + input: { workspaceId: args.workspaceId, limit: 1000 }, + }) + if (nextCursorKeys) throw new SelectorOptionsUnavailableError() return listSelectorResult( sandboxes .filter((sandbox) => !language || language === 'shell' || sandbox.language === language) diff --git a/apps/sim/lib/selectors/server/providers/mcp.test.ts b/apps/sim/lib/selectors/server/providers/mcp.test.ts new file mode 100644 index 00000000000..2b955f5de10 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/mcp.test.ts @@ -0,0 +1,71 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { discover } = vi.hoisted(() => ({ discover: vi.fn() })) +vi.mock('@/lib/mcp/application/use-cases', () => ({ + discoverMcpServerToolsUseCase: { execute: discover }, +})) + +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { mcpSelectorAttachments } from '@/lib/selectors/server/providers/mcp' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function args(request: ExecuteServerSelectorArgs['request']): ExecuteServerSelectorArgs { + return { + selectorKey: 'mcp.tools', + context: { mcpServerId: 'destination-server' }, + request, + scope: { kind: 'workspace', workspaceId: 'destination' }, + workspaceId: 'destination', + principal: { kind: 'personal_api_key', userId: 'user', keyId: 'key' }, + requesterUserId: 'user', + references: new Map(), + protectedValues: createSelectorProtectedValues(), + signal: new AbortController().signal, + } +} +async function execute(input: ExecuteServerSelectorArgs) { + const attachment = mcpSelectorAttachments['mcp.tools'] + if (attachment.destination === 'fixed') throw new Error('Expected bound MCP destination') + return attachment.execute(input, await attachment.destination.prepare(input)) +} +describe('MCP tools selector', () => { + beforeEach(() => vi.clearAllMocks()) + it('uses authorized discovery, projects names only, and pages the complete inventory', async () => { + discover.mockResolvedValue({ + tools: Array.from({ length: 101 }, (_, i) => ({ + name: `tool-${String(i).padStart(3, '0')}`, + description: 'not public selector metadata', + inputSchema: { secret: 'never project' }, + })), + }) + const input = args({ kind: 'list' }) + const first = await execute(input) + expect(discover).toHaveBeenCalledWith({ + principal: input.principal, + input: { + workspaceId: 'destination', + serverId: 'destination-server', + signal: input.signal, + requireComplete: true, + }, + }) + expect(first).toMatchObject({ kind: 'list', nextCursor: '100' }) + if (first.kind !== 'list') throw new Error('Expected list') + expect(first.items).toHaveLength(100) + expect(first.items[0]).toEqual({ id: 'tool-000', label: 'tool-000' }) + expect(await execute(args({ kind: 'list', cursor: '100' }))).toEqual({ + kind: 'list', + items: [{ id: 'tool-100', label: 'tool-100' }], + }) + }) + it('verifies actual tool names and propagates authorization refusal', async () => { + discover.mockResolvedValueOnce({ tools: [{ name: 'available' }] }) + expect(await execute(args({ kind: 'detail', id: 'missing' }))).toEqual({ + kind: 'detail', + item: null, + }) + discover.mockRejectedValueOnce(new Error('Destination access denied')) + await expect(execute(args({ kind: 'list' }))).rejects.toThrow('Destination access denied') + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/mcp.ts b/apps/sim/lib/selectors/server/providers/mcp.ts new file mode 100644 index 00000000000..302a0be768f --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/mcp.ts @@ -0,0 +1,53 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' +import { + definePreparedSelectorAttachment, + detailSelectorResult, + listSelectorResult, +} from '@/lib/selectors/server/types' + +/** The existing MCP use case binds the destination and credentials to an authorized server. */ +export const mcpSelectorAttachments = { + 'mcp.tools': definePreparedSelectorAttachment({ + integrationBlockTypes: ['mcp'], + destination: { + kind: 'credential-bound', + async prepare(args) { + if (!args.workspaceId || !args.context.mcpServerId) + throw new OrchestrationError( + 'validation', + 'MCP tool discovery requires a destination server' + ) + return discoverMcpServerToolsUseCase.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + serverId: args.context.mcpServerId, + signal: args.signal, + requireComplete: true, + }, + }) + }, + }, + async execute(args, result) { + const tools = result.tools + .map((tool) => ({ id: tool.name, label: tool.name })) + .sort((a, b) => a.id.localeCompare(b.id)) + if (args.request.kind === 'detail') { + const id = args.request.id + return detailSelectorResult(tools.find((tool) => tool.id === id) ?? null) + } + const { search, cursor } = args.request + const matches = search + ? tools.filter((tool) => tool.label.toLowerCase().includes(search.toLowerCase())) + : tools + const offset = cursor ? Number(cursor) : 0 + if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10_000) + throw new OrchestrationError('validation', 'Invalid MCP tools cursor') + return listSelectorResult( + matches.slice(offset, offset + 100), + offset + 100 < matches.length ? String(offset + 100) : undefined + ) + }, + }), +} diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index ef9a815f1af..4e067cc778e 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -17,6 +17,7 @@ import { jiraSelectorAttachments } from '@/lib/selectors/server/providers/jira' import { jsmSelectorAttachments } from '@/lib/selectors/server/providers/jsm' import { linearSelectorAttachments } from '@/lib/selectors/server/providers/linear' import { managedAgentSelectorAttachments } from '@/lib/selectors/server/providers/managed-agent' +import { mcpSelectorAttachments } from '@/lib/selectors/server/providers/mcp' import { microsoftSelectorAttachments } from '@/lib/selectors/server/providers/microsoft' import { mondaySelectorAttachments } from '@/lib/selectors/server/providers/monday' import { netsuiteSelectorAttachments } from '@/lib/selectors/server/providers/netsuite' @@ -34,6 +35,7 @@ import type { ServerSelectorAttachment } from '@/lib/selectors/server/types' export const serverSelectorRegistry = { ...internalSelectorAttachments, + ...mcpSelectorAttachments, ...airtableSelectorAttachments, ...asanaSelectorAttachments, ...attioSelectorAttachments, diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts index bc5ba8592f4..05546532f2d 100644 --- a/apps/sim/lib/selectors/server/types.ts +++ b/apps/sim/lib/selectors/server/types.ts @@ -1,4 +1,4 @@ -import type { SessionPrincipal } from '@sim/auth/principal' +import type { Principal, SessionPrincipal } from '@sim/auth/principal' import type { CredentialAccessResult } from '@/lib/auth/credential-access' import { MAX_SELECTOR_OPTIONS } from '@/lib/selectors/limits' import type { SelectorKey, ServerSelectorKey } from '@/lib/selectors/manifest' @@ -10,6 +10,11 @@ import type { SelectorScope, } from '@/lib/selectors/types' +export type SelectorPrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' } +> + export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-controlled' export type SelectorProtectedValueKind = 'secret' | 'reference' @@ -74,7 +79,7 @@ export interface ExecuteServerSelectorArgs { scope: SelectorScope workspaceId?: string organizationId?: string - principal: SessionPrincipal + principal: SelectorPrincipal requesterUserId: string credential?: AuthorizedSelectorCredential references: ReadonlyMap diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts index abf4e57c848..c97382e32f0 100644 --- a/apps/sim/lib/selectors/types.ts +++ b/apps/sim/lib/selectors/types.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' export const selectorContextKeys = [ 'oauthCredential', + 'mcpServerId', 'domain', 'teamId', 'projectId', diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index e445a2766ef..e29cc9ebd6e 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -24,6 +24,7 @@ import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-al import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' +import { WorkspaceOperationConflict } from '@/lib/workspaces/operations/receipts' import { v2CaughtOrchestrationError, v2Data, @@ -51,7 +52,7 @@ export const v2WorkflowErrorPolicies = { default: v2OrchestrationErrorPolicy, import: { render(error) { - if (error instanceof WorkflowImportError) { + if (error instanceof WorkflowImportError || error instanceof WorkspaceOperationConflict) { return v2ErrorForOrchestration(error.code, error.message, error.details) } return v2CaughtOrchestrationError(error) diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index 3b36db1a7c5..beaa06b2193 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -191,7 +191,7 @@ describe('workflow import and export application operations', () => { }) expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) - expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord) + expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord, { includeReferences: undefined }) expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 414557433f5..8d876afe525 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -9,6 +9,7 @@ import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { applyMappedWorkflowImport } from '@/lib/workflows/application/mapped-import' import { workflowOperations } from '@/lib/workflows/application/operations' import { resolveWorkflowFolderPath, @@ -23,9 +24,13 @@ import { type ImportedWorkflow, importWorkflowIntoWorkspaceTransition, } from '@/lib/workflows/operations/import-workflow' +import type { MappedImportOptions } from '@/lib/workflows/references/import-plan' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' -export interface ImportWorkflowInput { +export interface ImportWorkflowInput extends MappedImportOptions { + requestId?: string + previewFingerprint?: string workspaceId: string folderPath?: string name?: string @@ -34,11 +39,14 @@ export interface ImportWorkflowInput { } export interface ImportWorkflowResult { + operation?: WorkspaceOperationReport + replayed?: boolean workflow: ImportedWorkflow folderPath: string } export interface ExportWorkflowInput { + includeReferences?: boolean workflowId: string } @@ -61,6 +69,15 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: ImportWorkflowInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }): Promise { + if ( + input.mappings !== undefined || + input.bindings !== undefined || + input.dependentValues !== undefined || + input.previewFingerprint !== undefined || + input.requestId !== undefined + ) { + return applyMappedWorkflowImport(principal, input, context) + } const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') const attribution = resolvePrincipalAttribution(principal, { @@ -85,6 +102,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ } }, projectAudit({ result }) { + if (result.replayed) return [] return { action: AuditAction.WORKFLOW_CREATED, resourceType: AuditResourceType.WORKFLOW, @@ -100,15 +118,18 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ }, } }, - afterSuccess: ({ result }) => notifyWorkspaceWorkflowsChanged(result.workflow.workspaceId), + afterSuccess: ({ result }) => + result.operation ? undefined : notifyWorkspaceWorkflowsChanged(result.workflow.workspaceId), }) export const exportWorkflow = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.export, resolveContext: ({ input }: { input: ExportWorkflowInput }) => resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), - async execute({ context }): Promise { - const payload = await buildWorkflowExportPayload(context.workflow) + async execute({ context, input }): Promise { + const payload = await buildWorkflowExportPayload(context.workflow, { + includeReferences: input.includeReferences, + }) if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') const folderIndex = await loadActiveFolderPathIndex( context.workspaceId, diff --git a/apps/sim/lib/workflows/application/mapped-import.ts b/apps/sim/lib/workflows/application/mapped-import.ts new file mode 100644 index 00000000000..6f0adf42bca --- /dev/null +++ b/apps/sim/lib/workflows/application/mapped-import.ts @@ -0,0 +1,344 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow, workspace } from '@sim/db/schema' +import { assertFolderMutable } from '@sim/platform-authz/workflow' +import { generateId } from '@sim/utils/id' +import { and, eq, isNull, sql } from 'drizzle-orm' +import { capabilityGovernedPrincipalUserId } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { acquireFolderMutationLock } from '@/lib/folders/locks' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import type { + ImportWorkflowInput, + ImportWorkflowResult, +} from '@/lib/workflows/application/import-export' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' +import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' +import { resolveImportedMetadata } from '@/lib/workflows/operations/import-workflow' +import { createWorkflowInTransaction } from '@/lib/workflows/orchestration/workflow-lifecycle' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils' +import { + authorizeWorkflowBindingCredentials, + validateFinalWorkflowBindingTargets, + validateWorkflowBindingTargets, +} from '@/lib/workflows/references/binding-targets' +import { regenerateImportedVariableIds } from '@/lib/workflows/references/finalize-import' +import { finalizeBlockToolPositions } from '@/lib/workflows/references/finalize-tool-positions' +import { + inspectImportConfiguration, + publicImportConfiguration, + validateImportSelectorValues, +} from '@/lib/workflows/references/import-configuration' +import { buildWorkflowImportPlan } from '@/lib/workflows/references/import-plan' +import { + assertImportedInlineToolTitlesAvailable, + insertImportedInlineTools, + prepareImportedInlineTools, +} from '@/lib/workflows/references/inline-tools' +import { assertWorkflowPreviewFits } from '@/lib/workflows/references/preview-limits' +import { + type ActiveWorkspaceApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' +import { + findWorkspaceOperationReceipt, + insertWorkspaceOperationReceipt, + lockWorkspaceOperationRequest, + type WorkspaceOperationReport, + withWorkspaceOperationReplay, + workflowOperationFingerprint, +} from '@/lib/workspaces/operations/receipts' +import { regenerateWorkflowIds } from '@/stores/workflows/utils' + +function previewInput(input: ImportWorkflowInput) { + let workflowInput: unknown = input.workflow + if (typeof workflowInput === 'string') { + try { + workflowInput = JSON.parse(workflowInput) + } catch { + throw new OrchestrationError('validation', 'Workflow must contain valid JSON') + } + } + return { + workspaceId: input.workspaceId, + folderPath: input.folderPath ?? '/', + name: input.name, + description: input.description, + workflow: workflowInput, + mappings: input.mappings ?? [], + bindings: input.bindings ?? [], + dependentValues: input.dependentValues ?? [], + } +} + +async function prepareMappedImport(principal: Principal, input: ImportWorkflowInput) { + const resolution = await resolveWorkflowFolderPath(input.workspaceId, input.folderPath ?? '/') + await assertFolderMutable(resolution.folderId) + const plan = buildWorkflowImportPlan(input.workflow, input) + const configuration = await inspectImportConfiguration(plan, input, input.workspaceId) + assertWorkflowPreviewFits({ + bindings: plan.bindings, + unresolvedBindings: plan.unresolvedBindings, + configuration, + }) + await authorizeWorkflowBindingCredentials(principal, input.workspaceId, plan) + const targets = await validateWorkflowBindingTargets(db, input.workspaceId, plan) + const finalTargets = await validateFinalWorkflowBindingTargets(db, input.workspaceId, plan) + await validateImportSelectorValues(principal, input.workspaceId, configuration, input) + await admitWorkflowState(plan.state, { + workspaceId: input.workspaceId, + subjectUserId: capabilityGovernedPrincipalUserId(principal), + }) + const inlineTools = prepareImportedInlineTools(structuredClone(plan.state)) + await assertImportedInlineToolTitlesAvailable(db, input.workspaceId, inlineTools) + const fingerprintInput = { + input: previewInput(input), + folderId: resolution.folderId, + state: plan.state, + targets, + finalTargets, + configuration, + } + return { + resolution, + plan, + configuration: publicImportConfiguration(configuration), + fingerprintInput, + fingerprint: workflowOperationFingerprint(fingerprintInput), + } +} + +export const previewWorkflowImport = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.importPreview, + resolveContext: ({ input }: { input: ImportWorkflowInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input }) { + const prepared = await prepareMappedImport(principal, input) + const preview = { + previewFingerprint: prepared.fingerprint, + ready: + prepared.plan.unresolvedBindings.length === 0 && + !prepared.configuration.some((field) => field.required && !field.configured), + bindings: prepared.plan.bindings, + unresolvedBindings: prepared.plan.unresolvedBindings, + configuration: prepared.configuration, + unresolvedConfiguration: prepared.configuration.filter( + (field) => field.required && !field.configured + ), + discovery: [ + { + kind: 'credential', + command: 'sim credentials list --workspace ', + humanAuthorizationMayBeRequired: true, + }, + { + kind: 'selector', + command: + 'sim selectors list --workspace --selector-key --context @context.json', + humanAuthorizationMayBeRequired: false, + }, + ], + } + assertWorkflowPreviewFits(preview) + return preview + }, +}) + +function receiptResult(report: WorkspaceOperationReport, replayed: boolean): ImportWorkflowResult { + const imported = report.importedWorkflow + if (!imported) throw new OrchestrationError('internal', 'Import receipt is missing its result') + return { + workflow: { + ...imported, + createdAt: new Date(imported.createdAt), + updatedAt: new Date(imported.updatedAt), + }, + folderPath: imported.folderPath, + operation: report, + replayed, + } +} + +/** Called inside the authorized import operation; every business write shares this transaction. */ +export async function applyMappedWorkflowImport( + principal: Principal, + input: ImportWorkflowInput, + context: ActiveWorkspaceApplicationContext +): Promise { + if (!input.requestId || !input.previewFingerprint) + throw new WorkflowImportError( + 'validation', + 'Mapped imports require requestId and previewFingerprint' + ) + const requestId = input.requestId + const requestHash = workflowOperationFingerprint({ + ...previewInput(input), + previewFingerprint: input.previewFingerprint, + }) + return withWorkspaceOperationReplay( + { workspaceId: context.workspaceId, requestId, requestHash }, + (receipt) => receiptResult(receipt, true), + async () => { + const prepared = await prepareMappedImport(principal, input) + if ( + prepared.plan.unresolvedBindings.length || + prepared.configuration.some((field) => field.required && !field.configured) + ) + throw new WorkflowImportError('conflict', 'Import requires resource configuration', { + applied: false, + requestId, + reason: 'requires_configuration', + unresolvedBindings: prepared.plan.unresolvedBindings, + unresolvedConfiguration: prepared.configuration.filter( + (field) => field.required && !field.configured + ), + }) + if (prepared.fingerprint !== input.previewFingerprint) + throw new WorkflowImportError('conflict', 'Import preview is stale', { + applied: false, + requestId, + reason: 'stale_preview', + previewFingerprint: prepared.fingerprint, + }) + const importState = structuredClone(prepared.plan.state) + const variableIdMap = regenerateImportedVariableIds(importState) + const inlineTools = prepareImportedInlineTools(importState) + for (const block of Object.values(importState.blocks)) finalizeBlockToolPositions(block) + const regenerated = regenerateWorkflowIds(importState, { clearTriggerRuntimeValues: true }) + const edgeIdMap = new Map( + importState.edges.flatMap((edge, index) => + edge.id ? [[edge.id, regenerated.edges[index].id] as const] : [] + ) + ) + const normalized = prepareWorkflowStateForPersistence(regenerated).state + const admitted = await admitWorkflowState( + { ...regenerated, ...normalized }, + { + workspaceId: context.workspaceId, + subjectUserId: capabilityGovernedPrincipalUserId(principal), + } + ) + const metadata = resolveImportedMetadata( + previewInput(input).workflow, + input.name, + input.description + ) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '10s'`) + await lockWorkspaceOperationRequest(tx, context.workspaceId, requestId) + const replay = await findWorkspaceOperationReceipt( + tx, + context.workspaceId, + requestId, + requestHash + ) + if (replay) return receiptResult(replay, true) + await acquireFolderMutationLock(tx, context.workspaceId, 'workflow') + const [active] = await tx + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, context.workspaceId), isNull(workspace.archivedAt))) + .for('update') + if (!active) throw new OrchestrationError('not_found', 'Workspace not found') + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'workflow', tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folderId = resolveFolderPathFromIndex(folderIndex, input.folderPath ?? '/') + if (folderId === undefined || folderId !== prepared.resolution.folderId) + throw new WorkflowImportError('conflict', 'Destination folder changed after preview', { + applied: false, + requestId, + reason: 'stale_preview', + }) + let ancestorId = folderId + while (ancestorId) { + const ancestor = folderIndex.rowById.get(ancestorId)! + if (ancestor.locked) + throw new OrchestrationError('locked', 'Destination folder is locked') + ancestorId = ancestor.parentId + } + const targets = await validateWorkflowBindingTargets( + tx, + context.workspaceId, + prepared.plan, + { + lock: true, + } + ) + const finalTargets = await validateFinalWorkflowBindingTargets( + tx, + context.workspaceId, + prepared.plan, + { lock: true } + ) + if ( + workflowOperationFingerprint({ ...prepared.fingerprintInput, targets, finalTargets }) !== + input.previewFingerprint + ) + throw new WorkflowImportError('conflict', 'Import preview is stale', { + applied: false, + requestId, + reason: 'stale_preview', + }) + const created = await createWorkflowInTransaction(tx, { + ...metadata, + workspaceId: context.workspaceId, + folderId: prepared.resolution.folderId, + userId: attribution.attributedUserId, + deduplicate: true, + }) + await insertImportedInlineTools( + tx, + context.workspaceId, + attribution.attributedUserId, + inlineTools + ) + const saved = await saveAdmittedWorkflowState(tx, created.id, admitted) + if (!saved.success) + throw new OrchestrationError('internal', 'Failed to persist imported workflow') + await tx + .update(workflow) + .set({ variables: admitted.state.variables ?? {} }) + .where(eq(workflow.id, created.id)) + const report: WorkspaceOperationReport = { + operationId: generateId(), + requestId, + workspaceId: context.workspaceId, + kind: 'workflow_import', + applied: true, + status: 'completed', + resourceIds: [created.id, ...inlineTools.map((tool) => tool.id)], + issues: [], + idMap: Object.fromEntries([...regenerated.idMap, ...edgeIdMap, ...variableIdMap]), + importedWorkflow: { + id: created.id, + name: created.name, + description: created.description, + workspaceId: created.workspaceId, + folderId: created.folderId, + sortOrder: created.sortOrder, + folderPath: workflowFolderPathForId(folderIndex, created.folderId), + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + }, + } + await insertWorkspaceOperationReceipt(tx, requestHash, report) + await enqueueOutboxEvent(tx, 'workspace.workflows.changed', { + workspaceId: context.workspaceId, + }) + return receiptResult(report, false) + }) + } + ) +} diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 76ce83f6c60..1413767dae8 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -430,6 +430,17 @@ export const workflowOperations = { capability: 'none', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + /** + * permission-group-exempt: preview uses the same authoring permission and block policy as import. + */ + importPreview: defineWorkspaceOperation({ + id: 'workflows.import.preview', + oauthScope: 'api:write', + minimumRole: 'write', + workspaceApiKey: 'allow', + capability: 'none', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), // permission-group-exempt: importing is workflow authoring governed by workspace role; the blocks the payload carries are judged against allowedIntegrations before they are persisted import: defineWorkspaceOperation({ id: 'workflows.import', diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 4683bda7a6c..d9e3aa86fa7 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 { 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' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' @@ -55,6 +56,7 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ 'projectId', 'channelId', 'folderId', + 'sandboxId', ]) /** @@ -134,6 +136,8 @@ interface SanitizedWorkflowState { interface WorkflowSanitizationOptions { preserveEnvVars?: boolean + /** Allows only registered non-secret tool identities in portable exports. */ + preserveReferenceMetadata?: boolean /** * Withhold values whose interior cannot be projected safely once the payload leaves the * workspace — whole `table` values (see {@link OPAQUE_CREDENTIAL_BEARING_TYPES}) and every @@ -172,13 +176,15 @@ function isEnvironmentVariableReference(value: unknown): value is string { * and unknown schemas lack reliable secret annotations, so their generic parameters are withheld. */ function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOptions): unknown { - const tools = parseStoredToolInputValue(value) - if (!Array.isArray(value)) return null - if (tools.length !== value.length) return null + const { array, wasString } = coerceObjectArray(value) + if (!array) return null + if (wasString && !options.preserveReferenceMetadata) return null + const tools = parseStoredToolInputValue(array) + if (tools.length !== array.length) return null - let sanitizedValue: unknown = value + let sanitizedValue: unknown = array tools.forEach((tool, toolIndex) => { - const storedTool = value[toolIndex] + const storedTool = array[toolIndex] if (!isPlainRecord(storedTool)) { throw new Error(`Parsed tool input at index ${toolIndex} lost its object shape`) } @@ -203,12 +209,18 @@ function sanitizeToolInputValue(value: unknown, options: WorkflowSanitizationOpt const resolved = configByParamKey.get(paramKey) const nextValue = resolved?.authoritative ? sanitizeConfiguredSubBlockValue(paramValue, resolved.config, options) - : null + : options.preserveReferenceMetadata && + (tool.type === 'mcp' || tool.type === 'mcp-server-advanced') && + paramKey === 'toolName' && + typeof paramValue === 'string' && + /^[\w.-]{1,256}$/.test(paramValue) + ? paramValue + : null sanitizedValue = setValueAtPath(sanitizedValue, [toolIndex, 'params', paramKey], nextValue) }) }) - return sanitizedValue + return wasString ? JSON.stringify(sanitizedValue) : sanitizedValue } function sanitizeConfiguredSubBlockValue( @@ -217,6 +229,13 @@ function sanitizeConfiguredSubBlockValue( options: WorkflowSanitizationOptions ): unknown { if (config.type === 'oauth-input') return null + if ( + options.preserveReferenceMetadata && + config.type === 'mcp-tool-selector' && + typeof value === 'string' && + /^[\w.-]{1,512}$/.test(value) + ) + return value if (options.redactOpaqueCredentialInputs && config.type === 'tool-input') { return sanitizeToolInputValue(value, options) } diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index b6b4732e618..65e22702e44 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import type { PrincipalActor } from '@sim/auth/principal' import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db' +import { outboxEvent, workspaceOperationReceipt } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' @@ -9,6 +10,7 @@ import { env } from '@/lib/core/config/env' import { continueOutboxHandler, type DeferredOutboxHandlerResult, + deferOutboxHandler, enqueueOutboxEvent, type OutboxEventContext, type OutboxHandler, @@ -63,6 +65,7 @@ import { deleteSchedulesForWorkflow, } from '@/lib/workflows/schedules' import { emitWorkflowDeployedEvent } from '@/lib/workspace-events/emitter' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDeploymentOutbox') @@ -113,6 +116,7 @@ interface DeploymentCleanupOperationFence extends DeploymentOperationGeneration } export interface PrepareDeploymentV2Payload { + workspaceOperationId?: string protocolVersion: number operationId: string generation: number @@ -336,6 +340,39 @@ async function prepareDeploymentOperation( if (!operation || isTerminalNonActiveOperation(operation)) return assertPreparationPayloadMatchesOperation(payload, operation) + if (payload.workspaceOperationId) { + const [receipt] = await db + .select({ report: workspaceOperationReceipt.report }) + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.id, payload.workspaceOperationId)) + .limit(1) + const report = receipt?.report as WorkspaceOperationReport | undefined + if (!report || !report.deploymentOperationIds?.includes(payload.operationId)) + throw new NonRetryableDeploymentError( + 'Workspace sync receipt no longer admits this deployment' + ) + if (report.copyProgress?.status === 'failed') + throw new NonRetryableDeploymentError( + 'Selected workspace resources failed to copy', + 'resource_copy_failed' + ) + if (report.copyProgress?.status === 'pending') { + const [copy] = report.contentOutboxEventId + ? await db + .select({ status: outboxEvent.status }) + .from(outboxEvent) + .where(eq(outboxEvent.id, report.contentOutboxEventId)) + .limit(1) + : [] + if (!copy || copy.status === 'dead_letter') + throw new NonRetryableDeploymentError( + 'Workspace content copy could not complete', + 'resource_copy_failed' + ) + return deferOutboxHandler('Waiting for workspace resource copy', 1000, false) + } + } + const [workflowRecord] = await db .select() .from(workflowTable) @@ -1414,6 +1451,14 @@ function parsePrepareDeploymentV2Payload(payload: unknown): PrepareDeploymentV2P const checkpoints = parseDeploymentPreparationCheckpoints(record.checkpoints) return { + ...(record.workspaceOperationId === undefined + ? {} + : { + workspaceOperationId: parseRequiredString( + record.workspaceOperationId, + 'workspaceOperationId' + ), + }), protocolVersion, operationId, generation, diff --git a/apps/sim/lib/workflows/operations/export-workflow.test.ts b/apps/sim/lib/workflows/operations/export-workflow.test.ts index 07a32708768..a0250245f64 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.test.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: mocks.loadNormalized, + CREDENTIAL_SUBBLOCK_IDS: new Set(['credential', 'triggerCredentials', 'oauthCredential']), })) vi.mock('@/blocks/registry', () => ({ @@ -19,18 +20,34 @@ vi.mock('@/blocks/registry', () => ({ subBlocks: [{ id: 'tools', type: 'tool-input' }], outputs: {}, } - : { - name: 'Slack', - subBlocks: [ - { id: 'credential', type: 'oauth-input' }, - { id: 'botToken', type: 'short-input', password: true }, - { id: 'text', type: 'long-input' }, - ], - outputs: {}, - }, + : type === 'mcp' + ? { + name: 'MCP', + subBlocks: [ + { id: 'server', type: 'mcp-server-selector' }, + { + id: 'tool', + type: 'mcp-tool-selector', + dependsOn: ['server'], + selectorKey: 'mcp.tools', + }, + ], + outputs: {}, + } + : { + name: 'Slack', + subBlocks: [ + { id: 'credential', type: 'oauth-input' }, + { id: 'botToken', type: 'short-input', password: true }, + { id: 'text', type: 'long-input' }, + { id: 'headers', type: 'table' }, + ], + outputs: {}, + }, })) import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' +import { buildWorkflowImportPlan } from '@/lib/workflows/references/import-plan' /** * Asserts real tool params and outputs, which the global `@/tools/metadata` @@ -95,4 +112,215 @@ describe('buildWorkflowExportPayload', () => { expect(JSON.stringify(payload)).not.toContain('nested-credential-id') expect(JSON.stringify(payload)).not.toContain('nested-xoxb-secret') }) + + it('round trips retained environment occurrences while withholding opaque and prefixed secrets', async () => { + const tools = [ + { + type: 'slack', + toolId: 'slack_message', + params: { + credential: 'nested-credential-id', + botToken: 'nested-secret-prefix-{{NESTED_PREFIX_ONLY}}', + text: 'Hello {{SHARED_SECRET}}', + unknown: '{{OPAQUE_ONLY}}', + headers: [{ name: 'Authorization', value: '{{NESTED_HEADER_ONLY}}' }], + }, + }, + ] + mocks.loadNormalized.mockResolvedValue({ + blocks: { + slack: { + id: 'slack', + type: 'slack', + name: 'Slack', + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'top-credential-id' }, + botToken: { + id: 'botToken', + type: 'short-input', + value: 'raw-secret-prefix-{{PREFIX_ONLY}}', + }, + text: { id: 'text', type: 'long-input', value: 'Text {{SHARED_SECRET}}' }, + headers: { + id: 'headers', + type: 'table', + value: [ + { name: 'Authorization', value: 'Bearer raw-header-secret-{{HEADER_ONLY}}' }, + { name: 'Shared', value: '{{SHARED_SECRET}}' }, + ], + }, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 100, y: 0 }, + outputs: {}, + enabled: true, + subBlocks: { tools: { id: 'tools', type: 'tool-input', value: JSON.stringify(tools) } }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + const record = { + id: 'workflow-1', + name: 'Reports', + description: null, + workspaceId: 'workspace-1', + folderId: null, + variables: {}, + } + const payload = await buildWorkflowExportPayload(record, { includeReferences: true }) + expect(payload).not.toBeNull() + const references = payload!.referenceManifest!.references + expect(references.filter((reference) => reference.kind === 'env-var')).toEqual([ + { + kind: 'env-var', + sourceId: 'SHARED_SECRET', + required: true, + occurrences: [ + { + blockId: 'slack', + subBlockKey: 'text', + valuePath: [], + encoding: 'environment', + positions: [], + }, + { + blockId: 'agent', + subBlockKey: 'tools', + valuePath: [0, 'params', 'text'], + encoding: 'environment', + positions: [], + }, + ], + }, + ]) + expect( + references + .filter((reference) => reference.kind === 'credential') + .map((reference) => reference.sourceId) + .sort() + ).toEqual(['nested-credential-id', 'top-credential-id']) + const wire = JSON.stringify(payload) + for (const withheld of [ + 'raw-secret-prefix', + 'raw-header-secret', + 'nested-secret-prefix', + 'HEADER_ONLY', + 'PREFIX_ONLY', + 'OPAQUE_ONLY', + ]) + expect(wire).not.toContain(withheld) + expect(payload!.state.blocks.slack.subBlocks.headers.value).toBeNull() + expect(payload!.state.blocks.slack.subBlocks.botToken.value).toBeNull() + const plan = buildWorkflowImportPlan( + { ...payload! }, + { + mappings: references.map((reference) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + targetId: + reference.kind === 'env-var' + ? `TARGET_${reference.sourceId}` + : `target-${reference.sourceId}`, + })), + } + ) + expect(plan.unresolvedBindings).toEqual([]) + expect(plan.state.blocks.slack.subBlocks.text.value).toBe('Text {{TARGET_SHARED_SECRET}}') + const importedTools = plan.state.blocks.agent.subBlocks.tools.value + expect(typeof importedTools).toBe('string') + expect(JSON.parse(importedTools as string)[0].params.text).toBe( + 'Hello {{TARGET_SHARED_SECRET}}' + ) + const legacy = await buildWorkflowExportPayload(record) + expect(legacy!.referenceManifest).toBeUndefined() + expect(legacy!.state.blocks.agent.subBlocks.tools.value).toBeNull() + }) + + it.each(['mcp-source-server-search_docs', 'search_docs'])( + 'preserves safe MCP selection metadata only when opted in: %s', + async (value) => { + mocks.loadNormalized.mockResolvedValue({ + blocks: { + mcp: { + id: 'mcp', + type: 'mcp', + name: 'MCP', + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + subBlocks: { + server: { id: 'server', type: 'mcp-server-selector', value: 'source-server' }, + tool: { id: 'tool', type: 'mcp-tool-selector', value }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + const record = { + id: 'workflow-1', + name: 'MCP', + description: null, + workspaceId: 'workspace-1', + folderId: null, + variables: {}, + } + const portable = await buildWorkflowExportPayload(record, { includeReferences: true }) + expect(portable!.state.blocks.mcp.subBlocks.tool.value).toBe(value) + const plan = buildWorkflowImportPlan( + { ...portable! }, + { mappings: [{ kind: 'mcp-server', sourceId: 'source-server', targetId: 'target-server' }] } + ) + expect(plan.state.blocks.mcp.subBlocks.tool.value).toBe( + value.replace('source-server', 'target-server') + ) + const legacy = await buildWorkflowExportPayload(record) + expect(legacy!.state.blocks.mcp.subBlocks.tool.value).toBeNull() + } + ) + + it.each(['https://example.com/tools?token=secret-token', 'Bearer secret-token'])( + 'withholds unsafe MCP selector payloads with references enabled: %s', + async (value) => { + mocks.loadNormalized.mockResolvedValue({ + blocks: { + mcp: { + id: 'mcp', + type: 'mcp', + name: 'MCP', + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + subBlocks: { tool: { id: 'tool', type: 'mcp-tool-selector', value } }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + const payload = await buildWorkflowExportPayload( + { + id: 'workflow-1', + name: 'MCP', + description: null, + workspaceId: 'workspace-1', + folderId: null, + variables: {}, + }, + { includeReferences: true } + ) + expect(payload!.state.blocks.mcp.subBlocks.tool.value).toBeNull() + expect(JSON.stringify(payload)).not.toContain('secret-token') + } + ) }) diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts index 81ee238a3a6..b9c8c82629e 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -1,5 +1,11 @@ import type { Edge } from '@xyflow/react' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + buildWorkflowReferenceManifest, + readReferenceValue, +} from '@/lib/workflows/references/manifest' +import { ENV_REF_PATTERN } from '@/lib/workflows/references/remap-references' +import type { WorkflowReferenceManifest } from '@/lib/workflows/references/types' import { type ExportWorkflowState, sanitizeForExport, @@ -38,6 +44,7 @@ export interface WorkflowExportEdge { export interface WorkflowExportPayload { version: '1.0' + referenceManifest?: WorkflowReferenceManifest exportedAt: string workflow: { id: string @@ -80,6 +87,36 @@ function toExportedEdge(edge: Edge): WorkflowExportEdge { } } +/** Resource IDs travel in metadata; environment references require a retained expression. */ +function buildExportReferenceManifest( + sourceBlocks: ExportWorkflowState['state']['blocks'], + sanitizedBlocks: ExportWorkflowState['state']['blocks'] +): WorkflowReferenceManifest { + const manifest = buildWorkflowReferenceManifest(sourceBlocks) + return { + ...manifest, + references: manifest.references.flatMap((reference) => { + if (reference.kind !== 'env-var') return [reference] + const occurrences = reference.occurrences.filter((occurrence) => { + const block = Object.hasOwn(sanitizedBlocks, occurrence.blockId) + ? sanitizedBlocks[occurrence.blockId] + : undefined + const field = + block && Object.hasOwn(block.subBlocks, occurrence.subBlockKey) + ? block.subBlocks[occurrence.subBlockKey] + : undefined + const value = readReferenceValue(field?.value, occurrence.valuePath) + if (typeof value !== 'string') return false + for (const match of value.matchAll(ENV_REF_PATTERN)) { + if (match[1] === reference.sourceId) return true + } + return false + }) + return occurrences.length ? [{ ...reference, occurrences }] : [] + }), + } +} + /** * Loads the workflow's normalized state, sanitizes it, and assembles the * portable export envelope. Returns `null` when the workflow has no persisted @@ -111,25 +148,37 @@ function toExportedEdge(edge: Edge): WorkflowExportEdge { * as unresolved `{{ENV_VAR}}` references. */ export async function buildWorkflowExportPayload( - workflowData: ExportableWorkflowRecord + workflowData: ExportableWorkflowRecord, + options: { includeReferences?: boolean } = {} ): Promise { const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) if (!normalizedData) return null - const sanitized = sanitizeForExport({ - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: workflowData.name, - description: workflowData.description ?? undefined, + const sanitized = sanitizeForExport( + { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: workflowData.name, + description: workflowData.description ?? undefined, + }, + variables: parseWorkflowVariables(workflowData.variables), }, - variables: parseWorkflowVariables(workflowData.variables), - }) + options + ) return { version: '1.0', + ...(options.includeReferences + ? { + referenceManifest: buildExportReferenceManifest( + normalizedData.blocks, + sanitized.state.blocks + ), + } + : {}), exportedAt: sanitized.exportedAt, workflow: { id: workflowData.id, diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index a6c42d03dbf..396a7528a46 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -150,7 +150,7 @@ function unwrapResponseEnvelope(payload: unknown): unknown { * effective one — a caller could store an unbounded name simply by embedding it * in the payload instead of passing it as a field. */ -function resolveImportedMetadata( +export function resolveImportedMetadata( rawPayload: unknown, overrideName?: string, overrideDescription?: string diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 097a296a83e..e43a9829634 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -12,6 +12,7 @@ import { env } from '@/lib/core/config/env' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' +import type { DbOrTx } from '@/lib/db/types' import { captureServerEvent } from '@/lib/posthog/server' import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy' import { normalizedStringify } from '@/lib/workflows/comparison/normalize' @@ -180,21 +181,17 @@ export async function performFullDeploy( } } -async function performStableFullDeploy(params: { +/** Admits the supplied immutable graph and pending deployment work in the caller's transaction. */ +export async function prepareWorkflowSnapshotDeployment(params: { params: PerformFullDeployParams actorId: string requestId: string idempotencyKey: string -}): Promise { - const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) - if (!workflowState) { - return { - success: false, - error: 'Failed to load workflow state', - errorCode: 'validation', - } - } - + workflowState: WorkflowState + tx?: DbOrTx + workspaceOperationId?: string +}) { + const workflowState = params.workflowState const validation = await validateDeploymentState(workflowState.blocks) if (!validation.success) return validation @@ -206,6 +203,7 @@ async function performStableFullDeploy(params: { }) let outboxEventId: string | undefined const prepared = await prepareWorkflowDeployment({ + tx: params.tx, workflowId: params.params.workflowId, actorId: params.actorId, requestHash, @@ -230,18 +228,41 @@ async function performStableFullDeploy(params: { captureAnalytics: params.params.captureAnalytics, requestId: params.requestId, checkpoints: {}, + workspaceOperationId: params.workspaceOperationId, }) }, }) if (!prepared.success) { return { - success: false, + success: false as const, error: prepared.error, errorCode: mapPrepareFailureCode(prepared.reason), } } + return { success: true as const, operation: prepared.operation, outboxEventId } +} + +async function performStableFullDeploy(params: { + params: PerformFullDeployParams + actorId: string + requestId: string + idempotencyKey: string +}): Promise { + const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId) + if (!workflowState) { + return { + success: false, + error: 'Failed to load workflow state', + errorCode: 'validation', + } + } + + const prepared = await prepareWorkflowSnapshotDeployment({ ...params, workflowState }) + if (!prepared.success) return prepared + const outboxEventId = prepared.outboxEventId + const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId) const deploymentStatus = await getWorkflowDeploymentStatus(params.params.workflowId) const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, deploymentStatus) @@ -829,7 +850,7 @@ async function performStableVersionActivation(params: { if (!prepared.success) { return { - success: false, + success: false as const, error: prepared.error, errorCode: mapPrepareFailureCode(prepared.reason), } diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index d2098cbd418..e53a0ccec89 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -6,7 +6,7 @@ import { isFolderInWorkspace } from '@sim/platform-authz/workflow' import { getPostgresConstraintName, getPostgresErrorCode, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, ne } from 'drizzle-orm' -import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import type { DbOrTx } from '@/lib/db/types' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -180,6 +180,52 @@ async function isWorkflowFolderInWorkspace( return Boolean(row) } +/** Inserts only the workflow row so compound creation can commit its graph and receipt together. */ +export async function createWorkflowInTransaction(tx: DbOrTx, params: PerformCreateWorkflowParams) { + const folderId = params.folderId ?? null + if (!(await isWorkflowFolderInWorkspace(folderId, params.workspaceId, tx))) { + throw new OrchestrationError('not_found', 'Target folder not found') + } + const name = params.deduplicate + ? await deduplicateWorkflowName(params.name, params.workspaceId, folderId, tx) + : params.name + const sortOrder = + params.sortOrder ?? (await nextWorkflowSortOrder(params.workspaceId, folderId, tx)) + const now = new Date() + const row = { + id: params.id ?? generateId(), + userId: params.userId, + workspaceId: params.workspaceId, + folderId, + name, + description: params.description ?? null, + sortOrder, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + runCount: 0, + variables: {}, + } + if (!params.deduplicate) { + await tx.insert(workflow).values(row) + return row + } + for (let attempt = 0; attempt < WORKFLOW_NAME_DEDUPLICATION_ATTEMPTS; attempt++) { + const [inserted] = await tx + .insert(workflow) + .values(row) + .onConflictDoNothing() + .returning({ id: workflow.id }) + if (inserted) return row + row.name = await deduplicateWorkflowName(params.name, params.workspaceId, folderId, tx) + } + throw new OrchestrationError( + 'conflict', + 'Concurrent workflow creation prevented assigning an available name; retry this request' + ) +} + export async function performCreateWorkflowTransition( params: PerformCreateWorkflowParams ): Promise { diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts index 2e5213ef178..4e3cb4d62b6 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts @@ -149,12 +149,19 @@ export function remapVariableIdsInSubBlocks( export function remapWorkflowReferencesInSubBlocks( subBlocks: SubBlockRecord, workflowIdMap: Map | undefined, - options?: { clearUnmapped?: boolean; canonicalModes?: CanonicalModeOverrides } + options?: { + clearUnmapped?: boolean + preserveToolIndices?: boolean + canonicalModes?: CanonicalModeOverrides + resolve?: (sourceId: string, path: Array) => string | null | undefined + } ): SubBlockRecord { - if (!workflowIdMap?.size) return subBlocks + if (!workflowIdMap?.size && !options?.resolve) return subBlocks + const resolve = (id: string, path: Array) => + options?.resolve ? options.resolve(id, path) : workflowIdMap?.get(id) const clearUnmapped = options?.clearUnmapped ?? false - const remapScalar = (value: string): string => { - const mapped = workflowIdMap.get(value) + const remapScalar = (value: string, key: string): string => { + const mapped = resolve(value, [key]) if (mapped) return mapped return clearUnmapped ? '' : value } @@ -183,7 +190,7 @@ export function remapWorkflowReferencesInSubBlocks( typeof subBlock.value === 'string' && subBlock.value ) { - updated[key] = { ...subBlock, value: remapScalar(subBlock.value) } + updated[key] = { ...subBlock, value: remapScalar(subBlock.value, key) } continue } // Remap only the STRUCTURED multi-workflow lists: the logs block's `workflowSelector` and @@ -194,14 +201,23 @@ export function remapWorkflowReferencesInSubBlocks( baseKey === 'workflowSelector' || (subBlock.type === 'dropdown' && baseKey === 'workflowIds') ) { - const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped) + const remapped = remapWorkflowIdList( + subBlock.value, + (id) => resolve(id, [key]), + clearUnmapped + ) if (remapped !== subBlock.value) { updated[key] = { ...subBlock, value: remapped } continue } } if (subBlock.type === 'tool-input') { - const remapped = remapWorkflowInputTools(subBlock.value, workflowIdMap, clearUnmapped) + const remapped = remapWorkflowInputTools( + subBlock.value, + (id, index) => resolve(id, [key, index ?? 0, 'params', 'workflowId']), + clearUnmapped, + options?.preserveToolIndices + ) if (remapped !== subBlock.value) { updated[key] = { ...subBlock, value: remapped } continue @@ -244,11 +260,11 @@ export function remapWorkflowReferencesInSubBlocks( */ function remapWorkflowIdList( value: unknown, - workflowIdMap: Map, + resolve: (id: string, index?: number) => string | null | undefined, clearUnmapped: boolean ): unknown { const remapId = (id: string): string | null => { - const mapped = workflowIdMap.get(id) + const mapped = resolve(id) if (mapped) return mapped return clearUnmapped ? null : id } @@ -290,18 +306,19 @@ function remapWorkflowIdList( */ function remapWorkflowInputTools( value: unknown, - workflowIdMap: Map, - clearUnmapped: boolean + resolve: (id: string, index?: number) => string | null | undefined, + clearUnmapped: boolean, + preserveToolIndices = false ): unknown { const { array, wasString } = coerceObjectArray(value) if (!array) return value let changed = false - const next = array.flatMap((tool) => { + const next = array.flatMap((tool, index) => { if (!isRecordLike(tool) || tool.type !== 'workflow_input' || !isRecordLike(tool.params)) return [tool] const workflowId = tool.params.workflowId if (typeof workflowId !== 'string') return [tool] - const mapped = workflowIdMap.get(workflowId) + const mapped = resolve(workflowId, index) if (mapped) { if (mapped === workflowId) return [tool] changed = true @@ -309,6 +326,7 @@ function remapWorkflowInputTools( } if (clearUnmapped) { changed = true + if (preserveToolIndices) return [{ ...tool, params: { ...tool.params, workflowId: '' } }] return [] } return [tool] diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fc1f6984680..aeece86a6b7 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -193,9 +193,10 @@ export async function materializeDeploymentState( workflowId: string, version: DeploymentStateRow, workspaceId: string, - executor?: DbOrTx + executor?: DbOrTx, + options: { cache?: boolean } = {} ): Promise { - const cached = deployedStateCache.get(version.id) + const cached = options.cache === false ? undefined : deployedStateCache.get(version.id) if (cached) { return structuredClone(cached) } @@ -246,7 +247,7 @@ export async function materializeDeploymentState( deploymentVersionId: version.id, } - deployedStateCache.set(version.id, deployedState) + if (options.cache !== false) deployedStateCache.set(version.id, deployedState) return structuredClone(deployedState) } @@ -664,6 +665,32 @@ export function buildWorkflowDeploymentSnapshot( * union: the union collapses to a 500 at every caller, and this refusal is a * 403. */ +const ADMITTED_WORKFLOW_STATE = Symbol('admitted-workflow-state') + +export interface AdmittedWorkflowState { + readonly [ADMITTED_WORKFLOW_STATE]: true + readonly state: WorkflowState +} + +/** Evaluates authoring policy before a compound mutation acquires database locks. */ +export async function admitWorkflowState( + state: WorkflowState, + governance: WorkflowPersistGovernance +): Promise { + await assertNoWithheldBlockType(governance, Object.values(state.blocks)) + return { [ADMITTED_WORKFLOW_STATE]: true, state: structuredClone(state) } +} + +/** Persists a previously admitted graph on the caller's business transaction. */ +export async function saveAdmittedWorkflowState( + tx: DbOrTx, + workflowId: string, + admitted: AdmittedWorkflowState +): Promise<{ success: boolean; error?: string }> { + if (!admitted[ADMITTED_WORKFLOW_STATE]) throw new Error('Workflow state was not admitted') + return saveWorkflowToNormalizedTablesRaw(workflowId, admitted.state, tx) +} + export async function saveWorkflowToNormalizedTables( workflowId: string, state: WorkflowState, diff --git a/apps/sim/lib/workflows/references/binding-targets.ts b/apps/sim/lib/workflows/references/binding-targets.ts new file mode 100644 index 00000000000..d1f331aec36 --- /dev/null +++ b/apps/sim/lib/workflows/references/binding-targets.ts @@ -0,0 +1,357 @@ +import type { Principal } from '@sim/auth/principal' +import { + credential, + customBlock, + customTools, + document, + folder, + knowledgeBase, + mcpServers, + skill, + userTableDefinitions, + workflow, + workflowDeploymentVersion, + workspace, + workspaceEnvironment, + workspaceFiles, + workspaceSandbox, +} from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import type { WorkflowImportPlan } from '@/lib/workflows/references/import-plan' +import { + buildWorkflowReferenceManifest, + readReferenceValue, +} from '@/lib/workflows/references/manifest' +import { + filterExistingForkTargets, + getCredentialProvidersByIds, + getWorkspaceEnvKeys, +} from '@/lib/workflows/references/resources' +import type { WorkflowResourceKind } from '@/lib/workflows/references/types' +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' +import { getBlock } from '@/blocks/registry' + +/** Validates only destination resources. Source identifiers are never queried. */ +export async function validateWorkflowBindingTargets( + executor: DbOrTx, + workspaceId: string, + plan: WorkflowImportPlan, + options: { lock?: boolean } = {} +) { + const targets: Partial>> = {} + const workflowIds = new Set() + for (const binding of plan.bindings) { + if (!binding.targetId) continue + if (binding.kind === 'workflow') workflowIds.add(binding.targetId) + else (targets[binding.kind] ??= new Set()).add(binding.targetId) + } + const [existing, envKeys, providers, workflows, sandboxes] = await Promise.all([ + filterExistingForkTargets(executor, workspaceId, targets), + targets['env-var']?.size + ? getWorkspaceEnvKeys(executor, workspaceId) + : Promise.resolve(new Set()), + getCredentialProvidersByIds(executor, workspaceId, [...(targets.credential ?? [])]), + workflowIds.size + ? executor + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + inArray(workflow.id, [...workflowIds]) + ) + ) + : Promise.resolve([]), + targets.sandbox?.size + ? executor + .select({ + id: workspaceSandbox.id, + language: workspaceSandbox.language, + specHash: workspaceSandbox.specHash, + }) + .from(workspaceSandbox) + .where( + and( + eq(workspaceSandbox.workspaceId, workspaceId), + inArray(workspaceSandbox.id, [...targets.sandbox]) + ) + ) + : Promise.resolve([]), + ]) + for (const binding of plan.bindings) { + if (!binding.targetId) continue + const valid = + binding.kind === 'workflow' + ? workflows.some((row) => row.id === binding.targetId) + : binding.kind === 'env-var' + ? envKeys.has(binding.targetId) + : existing[binding.kind]?.has(binding.targetId) + if (!valid) + throw new OrchestrationError( + 'validation', + `Binding target is not an accessible ${binding.kind} in the destination workspace` + ) + const block = plan.sourceState.blocks[binding.occurrence.blockId] + if (binding.kind === 'sandbox') { + const sandbox = sandboxes.find((row) => row.id === binding.targetId) + const path = binding.occurrence.valuePath + const language = path.length + ? readReferenceValue(block.subBlocks[binding.occurrence.subBlockKey]?.value, [ + path[0], + 'params', + 'language', + ]) + : block.subBlocks.language?.value + if (language && language !== 'shell' && sandbox?.language !== language) + throw new OrchestrationError( + 'validation', + 'Sandbox language does not match the Function block' + ) + } + if (binding.kind === 'credential') { + let config = getBlock(block.type)?.subBlocks.find( + (field) => field.id === binding.occurrence.subBlockKey + ) + const path = binding.occurrence.valuePath + if (path.length > 0) { + const tool = readReferenceValue(block.subBlocks[binding.occurrence.subBlockKey]?.value, [ + path[0], + ]) + if (isRecordLike(tool) && typeof tool.type === 'string') { + config = getToolInputParamConfigs({ + tool: { + type: tool.type, + operation: typeof tool.operation === 'string' ? tool.operation : undefined, + toolId: typeof tool.toolId === 'string' ? tool.toolId : undefined, + params: isRecordLike(tool.params) ? tool.params : {}, + }, + toolIndex: typeof path[0] === 'number' ? path[0] : undefined, + parentCanonicalModes: block.data?.canonicalModes, + }).find( + (field) => + field.paramId === path.at(-1) || field.config.canonicalParamId === path.at(-1) + )?.config + } + } + const provider = providers.get(binding.targetId) + const service = config?.serviceId ? getServiceConfigByServiceId(config.serviceId) : null + if (!provider || !service || !credentialProviderMatchesService(provider, service)) + throw new OrchestrationError( + 'validation', + 'Credential provider does not match the bound field' + ) + } + if (binding.kind === 'knowledge-document') { + const kb = plan.bindings.find( + (parent) => + parent.kind === 'knowledge-base' && + parent.occurrence.blockId === binding.occurrence.blockId && + parent.occurrence.subBlockKey === + (binding.occurrence.valuePath.length + ? binding.occurrence.subBlockKey + : parent.occurrence.subBlockKey) && + parent.occurrence.valuePath[0] === binding.occurrence.valuePath[0] && + parent.targetId + ) + if (!kb) + throw new OrchestrationError( + 'validation', + 'A document binding requires its parent knowledge base binding' + ) + const [row] = await executor + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(document.id, binding.targetId), + eq(document.knowledgeBaseId, kb.targetId!), + eq(knowledgeBase.workspaceId, workspaceId) + ) + ) + .limit(1) + if (!row) + throw new OrchestrationError( + 'validation', + 'Document does not belong to the bound knowledge base' + ) + } + } + return { + providers: [...providers].sort(), + sandboxes: sandboxes.sort((a, b) => a.id.localeCompare(b.id)), + resources: plan.bindings.map(({ kind, targetId }) => ({ kind, targetId })), + revisions: await loadTargetRevisions(executor, workspaceId, plan, options.lock), + } +} + +/** Credential membership is judged against the authenticated human, never an attribution owner. */ +export async function authorizeWorkflowBindingCredentials( + principal: Principal, + workspaceId: string, + plan: WorkflowImportPlan +): Promise { + const ids = new Set( + plan.bindings + .filter((binding) => binding.kind === 'credential' && binding.targetId) + .map((binding) => binding.targetId!) + ) + if (!ids.size) return + if ( + principal.kind !== 'session' && + principal.kind !== 'personal_api_key' && + principal.kind !== 'oauth_access_token' + ) { + throw new OrchestrationError( + 'forbidden', + 'Credential binding requires a personal API key or OAuth user' + ) + } + for (const credentialId of ids) { + const access = await authorizeCredentialUseForAuth( + { success: true, userId: principal.userId }, + { workspaceId, credentialId } + ) + if (!access.ok || access.workspaceId !== workspaceId) + throw new OrchestrationError( + 'forbidden', + 'Credential binding requires access to the destination connection' + ) + } +} + +/** Hashes only authorized destination rows; no secret-bearing row is returned to the caller. */ +async function loadTargetRevisions( + executor: DbOrTx, + workspaceId: string, + plan: WorkflowImportPlan, + lock = false +) { + const revisions: Array<{ kind: string; revision: string | null }> = [] + const groups = new Map>() + for (const { kind, targetId } of plan.bindings) { + if (!targetId) continue + const ids = groups.get(kind) ?? new Set() + ids.add(targetId) + groups.set(kind, ids) + } + const customTypes = [...(groups.get('custom-block') ?? [])] + const customBlocks = customTypes.length + ? await executor + .select({ id: customBlock.id, workflowId: customBlock.workflowId }) + .from(customBlock) + .innerJoin(workspace, eq(workspace.organizationId, customBlock.organizationId)) + .where(and(eq(workspace.id, workspaceId), inArray(customBlock.type, customTypes))) + : [] + if (lock && customBlocks.length) { + const backingIds = [...new Set(customBlocks.map((row) => row.workflowId))].sort() + await executor.execute( + sql`SELECT id FROM ${workflow} WHERE id IN (${sql.join( + backingIds.map((id) => sql`${id}`), + sql`, ` + )}) ORDER BY id FOR SHARE` + ) + } + for (const [kind, ids] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { + const values = sql.join( + [...ids].map((id) => sql`${id}`), + sql`, ` + ) + let rows: SQL + switch (kind) { + case 'credential': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${credential} r WHERE r.id IN (${values})` + break + case 'table': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${userTableDefinitions} r WHERE r.id IN (${values})` + break + case 'knowledge-base': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${knowledgeBase} r WHERE r.id IN (${values})` + break + case 'knowledge-document': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${document} r WHERE r.id IN (${values})` + break + case 'sandbox': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${workspaceSandbox} r WHERE r.id IN (${values})` + break + case 'custom-block': + rows = sql`SELECT r.id, to_jsonb(r) || jsonb_build_object('deployment', d.state) AS state FROM ${customBlock} r LEFT JOIN ${workflowDeploymentVersion} d ON d.workflow_id = r.workflow_id AND d.is_active = true WHERE r.id IN (${ + customBlocks.length + ? sql.join( + customBlocks.map((row) => sql`${row.id}`), + sql`, ` + ) + : sql`NULL` + })` + break + case 'custom-tool': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${customTools} r WHERE r.id IN (${values})` + break + case 'mcp-server': + rows = sql`SELECT r.id, to_jsonb(r) - ARRAY['updated_at', 'last_connected_at', 'last_tools_refresh', 'tool_count', 'connection_status', 'last_error'] AS state FROM ${mcpServers} r WHERE r.id IN (${values})` + break + case 'skill': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${skill} r WHERE r.id IN (${values})` + break + case 'file': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${workspaceFiles} r WHERE r.key IN (${values}) AND r.workspace_id = ${workspaceId}` + break + case 'file-folder': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${folder} r WHERE r.workspace_id = ${workspaceId}` + break + case 'env-var': + rows = sql`SELECT e.key AS id, e.value AS state FROM ${workspaceEnvironment} r CROSS JOIN LATERAL jsonb_each(r.variables::jsonb) e WHERE r.workspace_id = ${workspaceId} AND e.key IN (${values})` + break + case 'workflow': + rows = sql`SELECT r.id, to_jsonb(r) AS state FROM ${workflow} r WHERE r.id IN (${values})` + break + default: + throw new OrchestrationError('validation', 'Unsupported binding kind') + } + if (lock) rows = sql`${rows} FOR SHARE OF r` + const [row] = await executor.execute<{ revision: string | null }>( + sql`SELECT md5(coalesce(string_agg(md5(state::text), '' ORDER BY id), '')) AS revision FROM (${rows}) revisions` + ) + revisions.push({ kind, revision: row?.revision ?? null }) + } + return revisions.sort((a, b) => a.kind.localeCompare(b.kind)) +} + +/** Rechecks protected references introduced by dependent selections against their final parents. */ +export async function validateFinalWorkflowBindingTargets( + executor: DbOrTx, + workspaceId: string, + plan: WorkflowImportPlan, + options: { lock?: boolean } = {} +) { + const manifest = buildWorkflowReferenceManifest(plan.state.blocks) + const bindings = manifest.references + .flatMap((reference) => + reference.occurrences.map((occurrence) => ({ + kind: reference.kind, + sourceId: reference.sourceId, + targetId: reference.sourceId, + required: reference.required, + occurrence, + })) + ) + .filter( + (binding) => + !plan.unresolvedBindings.some( + (unresolved) => + unresolved.kind === binding.kind && unresolved.sourceId === binding.sourceId + ) + ) + return validateWorkflowBindingTargets( + executor, + workspaceId, + { ...plan, manifest, sourceState: plan.state, bindings }, + options + ) +} diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts b/apps/sim/lib/workflows/references/custom-block-reconfigs.test.ts similarity index 96% rename from apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts rename to apps/sim/lib/workflows/references/custom-block-reconfigs.test.ts index 9e335097c70..8bc42798d06 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.test.ts +++ b/apps/sim/lib/workflows/references/custom-block-reconfigs.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references' +import type { ForkReferenceResolver } from '@/lib/workflows/references/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' const { mockResolveBinding } = vi.hoisted(() => ({ mockResolveBinding: vi.fn() })) @@ -11,7 +11,7 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ resolveCustomBlockToolBinding: mockResolveBinding, })) -import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs' +import { collectForkCustomBlockReconfigs } from '@/lib/workflows/references/custom-block-reconfigs' const PROD = 'custom_block_prod01' const UAT = 'custom_block_uat0001' diff --git a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts b/apps/sim/lib/workflows/references/custom-block-reconfigs.ts similarity index 96% rename from apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts rename to apps/sim/lib/workflows/references/custom-block-reconfigs.ts index 60ba062e578..f5610423f4b 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/custom-block-reconfigs.ts +++ b/apps/sim/lib/workflows/references/custom-block-reconfigs.ts @@ -2,12 +2,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' -import { isCustomBlockType } from '@/blocks/custom/build-config' -import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import { customBlockInputStorageKey, type ForkReferenceResolver, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import type { WorkflowBlockIdResolver } from '@/lib/workflows/references/types' +import { isCustomBlockType } from '@/blocks/custom/build-config' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('ForkCustomBlockReconfigs') @@ -20,7 +20,7 @@ interface CustomBlockReconfigItem { export interface CollectForkCustomBlockReconfigsParams { items: CustomBlockReconfigItem[] sourceStates: Map - resolveTargetBlockId: ForkBlockIdResolver + resolveTargetBlockId: WorkflowBlockIdResolver /** The promote resolver, to find each placed custom block's mapped target type. */ resolve: ForkReferenceResolver /** The TARGET workspace, which scopes the org the target block is resolved in. */ diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts b/apps/sim/lib/workflows/references/dependent-reconfigs.test.ts similarity index 99% rename from apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts rename to apps/sim/lib/workflows/references/dependent-reconfigs.test.ts index e1da0f5538d..75061776909 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/lib/workflows/references/dependent-reconfigs.test.ts @@ -15,12 +15,12 @@ vi.mock('@/lib/workflows/search-replace/indexer', () => ({ getToolInputParamConfigs: mockGetToolInputParamConfigs, })) -import { getBlock } from '@/blocks/registry' -import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { collectForkDependentReconfigs, collectForkResourceUsages, -} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs' +} from '@/lib/workflows/references/dependent-reconfigs' +import { getBlock } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { buildForkBlockIdResolver, deriveForkBlockId, diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts b/apps/sim/lib/workflows/references/dependent-reconfigs.ts similarity index 90% rename from apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts rename to apps/sim/lib/workflows/references/dependent-reconfigs.ts index d021847a89f..979c3ddaafa 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/lib/workflows/references/dependent-reconfigs.ts @@ -1,6 +1,13 @@ import { isRecordLike } from '@sim/utils/object' import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' +import { toScannerBlocks } from '@/lib/workflows/references/reference-scan' +import { + createCanonicalModeGates, + reconfigurableDependentIds, + scanWorkflowReferences, +} from '@/lib/workflows/references/remap-references' +import type { WorkflowBlockIdResolver } from '@/lib/workflows/references/types' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { buildSelectorContextFromBlock, @@ -22,13 +29,6 @@ import { import { resolveToolParamRequired } from '@/lib/workflows/tool-input/param-visibility' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' -import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan' -import { - createCanonicalModeGates, - reconfigurableDependentIds, - scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { ParameterVisibility } from '@/tools/types' @@ -46,9 +46,7 @@ interface ReconfigItem { /** * Parent anchor types a dependent selector can hang off, with the SelectorContext key * the new parent value is supplied under. A parent is a remappable resource (rewritten - * source->target on sync) whose target swap clears its dependents. MCP servers are - * intentionally excluded: their tool dependent has no `selectorKey` and a separate - * (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing. + * source->target on sync) whose target swap clears its dependents. */ const PARENT_ANCHORS: ReadonlyArray<{ subBlockType: string @@ -62,6 +60,11 @@ const PARENT_ANCHORS: ReadonlyArray<{ parentContextKey: 'knowledgeBaseId', }, { subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' }, + { + subBlockType: 'mcp-server-selector', + parentKind: 'mcp-server', + parentContextKey: 'mcpServerId', + }, ] interface EmitAnchoredParams { @@ -104,6 +107,7 @@ interface EmitAnchoredParams { */ paramVisibilityById?: Map out: ForkDependentReconfig[] + includeUnconfiguredRequired?: boolean } /** @@ -231,7 +235,13 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { (dependent.canonicalParamId && !configById.has(dependent.canonicalParamId) ? values[dependent.canonicalParamId] : undefined) - const rawSourceValue = typeof rawDependentValue === 'string' ? rawDependentValue : '' + const rawSourceValue = + typeof rawDependentValue === 'string' + ? rawDependentValue + : Array.isArray(rawDependentValue) && + rawDependentValue.every((value) => typeof value === 'string') + ? rawDependentValue.join(',') + : '' // Two independent invariants decide whether this row GATES the sync (it is always // offered either way - see the comment above the `condition` skip): // @@ -259,6 +269,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { ? { selectorKey: dependent.selectorKey } : { fieldType: dependent.type }), title: makeTitle(dependent), + ...(dependent.multiSelect ? { multiSelect: true } : {}), ...(toolName ? { toolName } : {}), ...(dependencyScope ? { dependencyScope } : {}), // Source value, so the always-on listing pre-fills a stable parent's selector. @@ -269,7 +280,11 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { // `rawSourceValue` flattens every non-string (a multi-select selector stores an // array) to `''`, which would report a populated field as blank and silently // un-gate it. `isNonEmptyValue` handles arrays and non-strings on purpose. - required: configuredRequired && isNonEmptyValue(rawDependentValue), + required: + configuredRequired && + (params.includeUnconfiguredRequired || + dependent.selectorKey === 'mcp.tools' || + isNonEmptyValue(rawDependentValue)), providesContextKey, consumesContextKeys, context: dependentContext, @@ -296,20 +311,21 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { * writes - the diff route emits both). * * `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for - * this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a + * this promote (see {@link buildWorkflowBlockIdResolver}); otherwise the modal would key a * re-pick by a derived id while the sync writes the block under its persisted counterpart, * and the override would silently miss. */ export function collectForkDependentReconfigs( items: ReconfigItem[], sourceStates: Map, - resolveTargetBlockId: ForkBlockIdResolver, + resolveTargetBlockId: WorkflowBlockIdResolver, /** * Which target mode to scan. Defaults to `replace` (the reconfigure UI, where the user re-picks * a dependent against a swapped parent). The pre-sync cleared-ref list passes `create` to surface * dependents a new target inherits that a remapped parent will clear (it can't be re-picked yet). */ - mode: 'create' | 'replace' = 'replace' + mode: 'create' | 'replace' = 'replace', + includeUnconfiguredRequired = false ): ForkDependentReconfig[] { const out: ForkDependentReconfig[] = [] for (const item of items) { @@ -330,6 +346,7 @@ export function collectForkDependentReconfigs( // scopeCanonicalModesForTool), falling back to the value heuristic only when none is set. emitAnchoredDependents({ config, + includeUnconfiguredRequired, values: sourceValues, contextBlockType: block.type, contextSubBlocks: subBlocks, @@ -353,6 +370,33 @@ export function collectForkDependentReconfigs( for (let index = 0; index < tools.length; index++) { const tool = tools[index] if (!isRecordLike(tool) || typeof tool.type !== 'string') continue + if ( + tool.type === 'mcp' && + isRecordLike(tool.params) && + typeof tool.params.serverId === 'string' && + tool.params.serverId + ) { + const value = typeof tool.params.toolName === 'string' ? tool.params.toolName : '' + out.push({ + parentKind: 'mcp-server', + parentSourceId: tool.params.serverId, + parentContextKey: 'mcpServerId', + targetWorkflowId: item.targetWorkflowId, + targetBlockId: resolveBlockId(), + blockName: block.name, + subBlockKey: `${cfg.id}[${index}].toolName`, + selectorKey: 'mcp.tools', + title: 'Tool', + toolName: typeof tool.title === 'string' ? tool.title : 'MCP Tool', + dependencyScope: `${cfg.id}[${index}]`, + currentValue: value, + sourceValue: value, + required: true, + consumesContextKeys: [], + context: {}, + }) + continue + } const toolConfig = getBlock(tool.type) if (!toolConfig) continue const toolParams = isRecordLike(tool.params) ? tool.params : {} @@ -395,6 +439,7 @@ export function collectForkDependentReconfigs( } emitAnchoredDependents({ config: toolConfig, + includeUnconfiguredRequired, values: toolValues, contextBlockType: tool.type, contextSubBlocks: toolContextSubBlocks, diff --git a/apps/sim/lib/workflows/references/finalize-import.ts b/apps/sim/lib/workflows/references/finalize-import.ts new file mode 100644 index 00000000000..44af2b08b7b --- /dev/null +++ b/apps/sim/lib/workflows/references/finalize-import.ts @@ -0,0 +1,30 @@ +import { generateId } from '@sim/utils/id' +import { + remapVariableIdsInSubBlocks, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** Replaces preview-stable variable labels only after the graph has been admitted for import. */ +export function regenerateImportedVariableIds(state: WorkflowState): Map { + const ids = new Map() + const variables: NonNullable = {} + for (const [sourceId, variable] of Object.entries(state.variables ?? {})) { + const id = generateId() + ids.set(sourceId, id) + variables[id] = { ...variable, id } + } + state.variables = variables + for (const block of Object.values(state.blocks)) { + const fields: SubBlockRecord = {} + for (const [key, field] of Object.entries(block.subBlocks)) fields[key] = { ...field } + const remapped = remapVariableIdsInSubBlocks(fields, ids) + for (const [key, field] of Object.entries(remapped)) { + block.subBlocks[key] = { + ...block.subBlocks[key], + value: field.value as (typeof block.subBlocks)[string]['value'], + } + } + } + return ids +} diff --git a/apps/sim/lib/workflows/references/finalize-tool-positions.ts b/apps/sim/lib/workflows/references/finalize-tool-positions.ts new file mode 100644 index 00000000000..d1115905921 --- /dev/null +++ b/apps/sim/lib/workflows/references/finalize-tool-positions.ts @@ -0,0 +1,36 @@ +import { isRecordLike } from '@sim/utils/object' +import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' +import { reindexCanonicalModesByPosition } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** Prunes unresolved tool placeholders after source-indexed overrides and reindexes canonical modes. */ +export function finalizeBlockToolPositions(block: BlockState): void { + for (const [key, field] of Object.entries(block.subBlocks)) { + if ( + !getBlock(block.type)?.subBlocks.some( + (definition) => definition.id === key && definition.type === 'tool-input' + ) + ) + continue + const { array, wasString } = coerceObjectArray(field.value) + if (!array) continue + const indices = new Map() + const tools = array.filter((tool, index) => { + if (!isRecordLike(tool)) return false + if (tool.type === 'custom-tool' && !tool.customToolId) return false + if ( + (tool.type === 'mcp' || tool.type === 'mcp-server-advanced') && + (!isRecordLike(tool.params) || !tool.params.serverId) + ) + return false + if (tool.type === 'workflow_input' && (!isRecordLike(tool.params) || !tool.params.workflowId)) + return false + indices.set(index, indices.size) + return true + }) + const canonicalModes = reindexCanonicalModesByPosition(indices, block.data?.canonicalModes) + if (canonicalModes) block.data = { ...block.data, canonicalModes } + field.value = (wasString ? JSON.stringify(tools) : tools) as typeof field.value + } +} diff --git a/apps/sim/lib/workflows/references/import-configuration.test.ts b/apps/sim/lib/workflows/references/import-configuration.test.ts new file mode 100644 index 00000000000..73e2b84950b --- /dev/null +++ b/apps/sim/lib/workflows/references/import-configuration.test.ts @@ -0,0 +1,113 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const { getOption } = vi.hoisted(() => ({ getOption: vi.fn() })) +vi.mock('@/lib/selectors/application/get-selector-option', () => ({ + getSelectorOption: { execute: getOption }, +})) +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: vi.fn(() => []), +})) + +import { + inspectImportConfiguration, + validateImportSelectorValues, +} from '@/lib/workflows/references/import-configuration' +import { buildWorkflowImportPlan } from '@/lib/workflows/references/import-plan' +import { getBlock } from '@/blocks/registry' + +const principal = { kind: 'personal_api_key', userId: 'user', keyId: 'key' } as const +const agent = { type: 'agent', subBlocks: [{ id: 'tools', type: 'tool-input' }] } as BlockConfig +const mcp = { + type: 'mcp', + subBlocks: [ + { id: 'server', type: 'mcp-server-selector', required: true }, + { + id: 'tool', + type: 'mcp-tool-selector', + selectorKey: 'mcp.tools', + dependsOn: ['server'], + required: true, + }, + ], +} as BlockConfig +function source(toolName: string | null): WorkflowState { + return { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [{ type: 'mcp', params: { serverId: 'source-server', toolName } }], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} +const mappings = [ + { kind: 'mcp-server' as const, sourceId: 'source-server', targetId: 'destination-server' }, +] +describe('mapped import configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getBlock).mockImplementation((type) => + type === 'agent' ? agent : type === 'mcp' ? mcp : undefined + ) + getOption.mockImplementation(async ({ input }) => ({ id: input.id, label: input.id })) + }) + it('requires the lost MCP tool name in a legacy export and accepts a source-indexed repair', async () => { + const plan = buildWorkflowImportPlan(source(null), { mappings }) + expect(await inspectImportConfiguration(plan, { mappings }, 'destination')).toEqual([ + expect.objectContaining({ + blockId: 'agent', + subBlockKey: 'tools[0].toolName', + required: true, + configured: false, + selectorKey: 'mcp.tools', + context: { mcpServerId: 'destination-server' }, + }), + ]) + const options = { + mappings, + dependentValues: [{ blockId: 'agent', subBlockKey: 'tools[0].toolName', value: 'search' }], + } + const repaired = buildWorkflowImportPlan(source(null), options) + const fields = await inspectImportConfiguration(repaired, options, 'destination') + await validateImportSelectorValues(principal, 'destination', fields, options) + expect(fields[0].configured).toBe(true) + expect(repaired.state.blocks.agent.subBlocks.tools.value).toEqual([ + expect.objectContaining({ + toolId: 'mcp-destination-server-search', + params: { serverId: 'destination-server', toolName: 'search' }, + }), + ]) + }) + it('marks a preserved MCP name unavailable when the destination does not offer it', async () => { + getOption.mockResolvedValue(null) + const plan = buildWorkflowImportPlan(source('old-tool'), { mappings }) + const fields = await inspectImportConfiguration(plan, { mappings }, 'destination') + await validateImportSelectorValues(principal, 'destination', fields, { mappings }) + expect(fields[0]).toMatchObject({ required: true, configured: false }) + }) + it('does not require a tool name for an advanced server-wide binding', async () => { + const state = source(null) + state.blocks.agent.subBlocks.tools.value = [ + { type: 'mcp-server-advanced', params: { serverId: 'source-server' } }, + ] + const plan = buildWorkflowImportPlan(state, { mappings }) + expect(await inspectImportConfiguration(plan, { mappings }, 'destination')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workflows/references/import-configuration.ts b/apps/sim/lib/workflows/references/import-configuration.ts new file mode 100644 index 00000000000..30977b0a6c2 --- /dev/null +++ b/apps/sim/lib/workflows/references/import-configuration.ts @@ -0,0 +1,199 @@ +import type { Principal } from '@sim/auth/principal' +import { isRecordLike } from '@sim/utils/object' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getSelectorManifestEntry, + isSelectorReady, + type SelectorKey, +} from '@/lib/selectors/manifest' +import type { SelectorContext } from '@/lib/selectors/types' +import { collectForkCustomBlockReconfigs } from '@/lib/workflows/references/custom-block-reconfigs' +import { collectForkDependentReconfigs } from '@/lib/workflows/references/dependent-reconfigs' +import type { + MappedImportOptions, + WorkflowImportPlan, +} from '@/lib/workflows/references/import-plan' +import { readReferenceValue } from '@/lib/workflows/references/manifest' +import { + parseCustomBlockInputStorageKey, + parseNestedDependentKey, + readTargetDraftDependentValue, +} from '@/lib/workflows/references/remap-references' +import { workflowSelectorValidator } from '@/lib/workflows/references/selector-values' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' +import { + type CanonicalModeOverrides, + scopeCanonicalModesForTool, +} from '@/lib/workflows/subblocks/visibility' + +export interface ImportConfigurationField { + blockId: string + subBlockKey: string + title: string + required: boolean + configured: boolean + selectorKey?: string + multiSelect?: boolean + context: Record + requiresAuthentication: boolean +} + +/** Uses the same registered dependency graph as sync; all public identities remain source identities. */ +export async function inspectImportConfiguration( + plan: WorkflowImportPlan, + options: MappedImportOptions, + workspaceId: string +) { + const identity = 'import' + const items = [ + { sourceWorkflowId: identity, targetWorkflowId: identity, mode: 'create' as const }, + ] + const sourceStates = new Map([[identity, plan.sourceState]]) + const resolveBlock = (_workflowId: string, blockId: string) => blockId + const fields = [ + ...collectForkDependentReconfigs(items, sourceStates, resolveBlock, 'create', true), + ...(await collectForkCustomBlockReconfigs({ + items, + sourceStates, + resolveTargetBlockId: resolveBlock, + targetWorkspaceId: workspaceId, + resolve: (kind, id) => + plan.bindings.find((binding) => binding.kind === kind && binding.sourceId === id)?.targetId, + })), + ] + for (const value of options.dependentValues ?? []) { + if ( + !fields.some( + (field) => field.targetBlockId === value.blockId && field.subBlockKey === value.subBlockKey + ) + ) { + throw new OrchestrationError( + 'validation', + `Dependent field ${value.subBlockKey} is not configurable; use a resource binding for resource selections` + ) + } + const conflict = plan.bindings.find( + (binding) => + binding.occurrence.blockId === value.blockId && + binding.targetId && + (binding.occurrence.valuePath.length + ? `${binding.occurrence.subBlockKey}[${binding.occurrence.valuePath[0]}].${binding.occurrence.valuePath.at(-1)}` + : binding.occurrence.subBlockKey) === value.subBlockKey + ) + if (conflict && conflict.targetId !== value.value) + throw new OrchestrationError( + 'validation', + 'Dependent value conflicts with a resource binding' + ) + } + return fields.map((field) => { + const block = plan.state.blocks[field.targetBlockId] + const custom = parseCustomBlockInputStorageKey(field.subBlockKey) + const value = custom + ? String(block.subBlocks[custom.fieldId]?.value ?? '') + : readTargetDraftDependentValue(block.subBlocks, block.subBlocks, field.subBlockKey) + let context: SelectorContext = {} + let requiresAuthentication = false + if (field.selectorKey) { + const nested = parseNestedDependentKey(field.subBlockKey) + let blockType = block.type + let subBlocks: Record = block.subBlocks + let canonicalModes: CanonicalModeOverrides | undefined = block.data?.canonicalModes + if (nested) { + const tool = readReferenceValue(block.subBlocks[nested.toolInputId]?.value, [nested.index]) + if (isRecordLike(tool) && typeof tool.type === 'string') { + blockType = tool.type + subBlocks = Object.fromEntries( + Object.entries({ + operation: tool.operation, + ...(isRecordLike(tool.params) ? tool.params : {}), + }).map(([key, value]) => [key, { value }]) + ) + canonicalModes = scopeCanonicalModesForTool( + block.data?.canonicalModes, + nested.index, + blockType + ) + } + } + context = buildSelectorContextFromBlock(blockType, subBlocks, { + selectorKey: field.selectorKey as SelectorKey, + canonicalModes, + triggerMode: block.triggerMode, + staticContext: field.context.mimeType ? { mimeType: field.context.mimeType } : undefined, + }) + if (field.selectorKey === 'mcp.tools' && nested) { + const tool = readReferenceValue(block.subBlocks[nested.toolInputId]?.value, [nested.index]) + context = + isRecordLike(tool) && + isRecordLike(tool.params) && + typeof tool.params.serverId === 'string' + ? { mcpServerId: tool.params.serverId } + : {} + } + const manifest = getSelectorManifestEntry(field.selectorKey as SelectorKey) + requiresAuthentication = + manifest.context.allowed.includes('oauthCredential') && !context.oauthCredential + for (const sensitive of manifest.context.sensitive ?? []) + if (context[sensitive] && !/^\{\{[\s\w]+\}\}$/.test(context[sensitive])) + requiresAuthentication = true + } + return { + blockId: block.id, + subBlockKey: field.subBlockKey, + title: field.title, + required: field.required, + configured: value !== '', + selectorKey: field.selectorKey, + multiSelect: field.multiSelect, + context, + requiresAuthentication, + value, + } + }) +} + +/** Validates submitted provider choices through the existing authorized selector operation. */ +export async function validateImportSelectorValues( + principal: Principal, + workspaceId: string, + fields: Awaited>, + options: MappedImportOptions +) { + const validate = workflowSelectorValidator(principal, workspaceId) + for (const field of fields) { + const supplied = options.dependentValues?.some( + (value) => value.blockId === field.blockId && value.subBlockKey === field.subBlockKey + ) + if (!field.selectorKey || !field.value || (!supplied && field.selectorKey !== 'mcp.tools')) + continue + if (!isSelectorReady(field.selectorKey as SelectorKey, field.context)) { + field.configured = false + continue + } + if (!(await validate({ ...field, selectorKey: field.selectorKey }))) { + if (supplied) + throw new OrchestrationError( + 'validation', + `${field.title} is not an available choice under its destination dependencies` + ) + field.configured = false + } + } +} + +/** Strips values and sensitive selector dependencies before returning configuration instructions. */ +export function publicImportConfiguration( + fields: Awaited> +): ImportConfigurationField[] { + return fields.map(({ value: _value, context, ...field }) => { + const safe: Record = {} + const sensitive = field.selectorKey + ? (getSelectorManifestEntry(field.selectorKey as SelectorKey).context.sensitive ?? []) + : [] + for (const [key, value] of Object.entries(context)) + if (!sensitive.some((field) => field === key) || /^\{\{[\s\w]+\}\}$/.test(value)) + safe[key] = value + return { ...field, context: safe } + }) +} diff --git a/apps/sim/lib/workflows/references/import-plan.test.ts b/apps/sim/lib/workflows/references/import-plan.test.ts new file mode 100644 index 00000000000..51b20d44441 --- /dev/null +++ b/apps/sim/lib/workflows/references/import-plan.test.ts @@ -0,0 +1,315 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: vi.fn(() => []), +})) + +import { regenerateImportedVariableIds } from '@/lib/workflows/references/finalize-import' +import { finalizeBlockToolPositions } from '@/lib/workflows/references/finalize-tool-positions' +import { buildWorkflowImportPlan } from '@/lib/workflows/references/import-plan' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { getBlock } from '@/blocks/registry' + +const config = { + subBlocks: [ + { id: 'credential', type: 'oauth-input', serviceId: 'gmail', title: 'Connection' }, + { id: 'credential2', type: 'oauth-input', serviceId: 'gmail', title: 'Second connection' }, + { id: 'sandboxId', type: 'combobox', selectorKey: 'workspace.sandboxes', title: 'Sandbox' }, + { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + title: 'Knowledge', + multiSelect: true, + }, + { id: 'code', type: 'code', title: 'Code' }, + { id: 'headers', type: 'table', title: 'Headers' }, + { id: 'password', type: 'short-input', password: true, title: 'Password' }, + { id: 'files', type: 'file-upload', title: 'Files' }, + { id: 'tools', type: 'tool-input', title: 'Tools' }, + ], +} as BlockConfig + +function state(values: Record): WorkflowState { + const subBlocks: WorkflowState['blocks'][string]['subBlocks'] = {} + for (const [key, value] of Object.entries(values)) { + const field = config.subBlocks.find((field) => field.id === key) + subBlocks[key] = { + id: key, + type: field?.type ?? 'oauth-input', + value: value as (typeof subBlocks)[string]['value'], + } + } + return { + blocks: { + source: { + id: 'source', + type: 'function', + name: 'Function', + position: { x: 0, y: 0 }, + subBlocks, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} + +beforeEach(() => { + vi.mocked(getBlock).mockReturnValue(config) +}) + +describe('portable workflow references', () => { + it('rejects duplicate variable identities before they collapse into one imported variable', () => { + const source = state({}) + source.variables = { + first: { id: 'duplicate', name: 'first', type: 'string', value: 'one' }, + second: { id: 'duplicate', name: 'second', type: 'string', value: 'two' }, + } + expect(() => buildWorkflowImportPlan(source, {})).toThrow('duplicate source identifiers') + }) + + it('rejects ambiguous block and variable identities in the returned identity map', () => { + const source = state({}) + source.variables = { + source: { id: 'source', name: 'value', type: 'string', value: 'one' }, + } + expect(() => buildWorkflowImportPlan(source, {})).toThrow('must be distinct') + }) + + it.each(['source', 'edge'])('rejects reused edge identity %s', (id) => { + const source = state({}) + source.edges = [ + { id: 'edge', source: 'source', target: 'source' }, + { id, source: 'source', target: 'source' }, + ] + expect(() => buildWorkflowImportPlan(source, {})).toThrow('must be distinct') + }) + + it.each([ + { key: 'source/file.pdf' }, + JSON.stringify({ key: 'source/file.pdf' }), + [{ key: 'source/file.pdf' }, { key: 'source/file.pdf' }], + JSON.stringify([{ key: 'source/file.pdf' }, { key: 'source/file.pdf' }]), + ])('round trips file codec shape %j with every occurrence', (value) => { + const source = state({ files: value }) + const manifest = buildWorkflowReferenceManifest(source.blocks) + const exported = { + ...sanitizeForExport(source, { includeReferences: true }), + referenceManifest: manifest, + } + const plan = buildWorkflowImportPlan(exported, { + mappings: [{ kind: 'file', sourceId: 'source/file.pdf', targetId: 'target/file.pdf' }], + }) + const count = + Array.isArray(value) || (typeof value === 'string' && value.startsWith('[')) ? 2 : 1 + expect(manifest.references[0].occurrences[0].positions).toHaveLength(count) + expect(plan.state.blocks.source.subBlocks.files.value).toEqual( + Array.from({ length: count }, () => ({ key: 'target/file.pdf' })) + ) + }) + + it('keeps an explicit custom-tool mapping instead of importing the attached inline code', () => { + const plan = buildWorkflowImportPlan( + state({ + tools: [ + { + type: 'custom-tool', + customToolId: 'source-tool', + title: 'Example', + code: 'return 1', + schema: {}, + }, + ], + }), + { + mappings: [{ kind: 'custom-tool', sourceId: 'source-tool', targetId: 'target-tool' }], + } + ) + expect(plan.state.blocks.source.subBlocks.tools.value).toEqual([ + { type: 'custom-tool', customToolId: 'target-tool', title: 'Example' }, + ]) + }) + + it('treats an unmapped inline declaration as self-contained', () => { + const plan = buildWorkflowImportPlan( + state({ + tools: [ + { + type: 'custom-tool', + customToolId: 'old-tool', + title: 'Example', + code: 'return 1', + schema: {}, + }, + ], + }), + {} + ) + expect(plan.bindings).toEqual([]) + expect(plan.state.blocks.source.subBlocks.tools.value).toEqual([ + { type: 'custom-tool', title: 'Example', code: 'return 1', schema: {} }, + ]) + }) + + it('removes optional placeholders once and keeps later tool modes on the same tool', () => { + const source = state({ + tools: [ + { type: 'custom-tool', customToolId: 'unmapped' }, + { type: 'mcp', params: { serverId: 'source-server', toolName: 'lookup' } }, + ], + }) + source.blocks.source.data = { canonicalModes: { '1:gmail:labelIds': 'advanced' } } + const plan = buildWorkflowImportPlan(source, { + mappings: [{ kind: 'mcp-server', sourceId: 'source-server', targetId: 'target-server' }], + }) + finalizeBlockToolPositions(plan.state.blocks.source) + expect(plan.state.blocks.source.subBlocks.tools.value).toHaveLength(1) + expect(plan.state.blocks.source.data?.canonicalModes).toEqual({ + '0:gmail:labelIds': 'advanced', + }) + }) + + it('rejects unregistered workflow selector and nested tool locators', () => { + const source = state({ + workflowSelector: 'hidden-workflow', + inventedTools: [{ type: 'workflow_input', params: { workflowId: 'hidden-workflow' } }], + }) + source.blocks.source.subBlocks.workflowSelector.type = 'workflow-selector' + source.blocks.source.subBlocks.inventedTools.type = 'tool-input' + expect(buildWorkflowReferenceManifest(source.blocks).references).toEqual([]) + }) + + it('regenerates variables and their references after deterministic previews', () => { + const source = state({ code: '' }) + source.variables = { + 'source-variable': { id: 'source-variable', name: 'value', type: 'string', value: 'example' }, + } + const ids = regenerateImportedVariableIds(source) + expect(ids.get('source-variable')).not.toBe('source-variable') + expect(Object.keys(source.variables)).toEqual([ids.get('source-variable')]) + }) + it('tracks both occurrences of a credential and excludes opaque values and unregistered fields', () => { + const source = state({ + credential: 'cred-source', + credential2: 'cred-source', + sandboxId: 'sandbox-source', + password: 'secret-password', + headers: [{ name: 'Authorization', value: 'Bearer secret-token' }], + invented: 'secret-disguised-as-id', + }) + const manifest = buildWorkflowReferenceManifest(source.blocks) + expect( + manifest.references.find((reference) => reference.kind === 'credential')?.occurrences + ).toHaveLength(2) + expect(manifest.references.some((reference) => reference.kind === 'sandbox')).toBe(true) + expect(JSON.stringify(manifest)).not.toMatch( + /secret-password|secret-token|secret-disguised-as-id/ + ) + }) + + it('rehydrates a sanitized export and rewrites both credential fields before ID regeneration', () => { + const source = state({ credential: 'cred-source', credential2: 'cred-source' }) + const exported = { + ...sanitizeForExport(source), + referenceManifest: buildWorkflowReferenceManifest(source.blocks), + } + const plan = buildWorkflowImportPlan(exported, { + mappings: [{ kind: 'credential', sourceId: 'cred-source', targetId: 'cred-target' }], + }) + expect(plan.unresolvedBindings).toEqual([]) + expect(plan.state.blocks.source.id).toBe('source') + expect(plan.state.blocks.source.subBlocks.credential.value).toBe('cred-target') + expect(plan.state.blocks.source.subBlocks.credential2.value).toBe('cred-target') + }) + + it('does not trust an empty manifest to hide live graph references', () => { + const source = state({ credential: 'foreign-credential' }) + const plan = buildWorkflowImportPlan( + { state: source, referenceManifest: { version: 1, references: [] } }, + {} + ) + expect(plan.unresolvedBindings).toHaveLength(1) + expect(plan.state.blocks.source.subBlocks.credential.value).not.toBe('foreign-credential') + }) + + it('rejects conflicting field and resource mapping instructions', () => { + expect(() => + buildWorkflowImportPlan(state({ credential: 'cred-source' }), { + mappings: [{ kind: 'credential', sourceId: 'cred-source', targetId: 'cred-one' }], + bindings: [ + { + kind: 'credential', + blockId: 'source', + subBlockKey: 'credential', + valuePath: [], + encoding: 'scalar', + targetId: 'cred-two', + }, + ], + }) + ).toThrow('conflicts') + }) + + it('preserves ordered repeated resources in collection codecs', () => { + const source = state({ knowledgeBaseId: ['kb-b', 'kb-a', 'kb-b'] }) + const exported = { + ...sanitizeForExport(source), + referenceManifest: buildWorkflowReferenceManifest(source.blocks), + } + const plan = buildWorkflowImportPlan(exported, { + mappings: [ + { kind: 'knowledge-base', sourceId: 'kb-a', targetId: 'target-a' }, + { kind: 'knowledge-base', sourceId: 'kb-b', targetId: 'target-b' }, + ], + }) + expect(plan.state.blocks.source.subBlocks.knowledgeBaseId.value).toEqual([ + 'target-b', + 'target-a', + 'target-b', + ]) + }) + + it('rewrites spaced environment references once without cascading mappings', () => { + const plan = buildWorkflowImportPlan(state({ code: '{{ A }} {{B}}' }), { + mappings: [ + { kind: 'env-var', sourceId: 'A', targetId: 'B' }, + { kind: 'env-var', sourceId: 'B', targetId: 'C' }, + ], + }) + expect(plan.state.blocks.source.subBlocks.code.value).toBe('{{B}} {{C}}') + }) + + for (const blockId of ['__proto__', 'constructor', 'prototype']) { + it(`rejects prototype locators (${blockId}) before writing any object`, () => { + expect(() => + buildWorkflowImportPlan( + { + state: state({}), + referenceManifest: { + version: 1, + references: [ + { + kind: 'custom-block', + sourceId: 'custom_block_attacker', + required: true, + occurrences: [ + { blockId, subBlockKey: 'type', valuePath: [], encoding: 'scalar' }, + ], + }, + ], + }, + }, + {} + ) + ).toThrow() + expect(Object.hasOwn(Object.prototype, 'type')).toBe(false) + }) + } +}) diff --git a/apps/sim/lib/workflows/references/import-plan.ts b/apps/sim/lib/workflows/references/import-plan.ts new file mode 100644 index 00000000000..f29d73c3165 --- /dev/null +++ b/apps/sim/lib/workflows/references/import-plan.ts @@ -0,0 +1,462 @@ +import { isRecordLike, omit } from '@sim/utils/object' +import { workflowReferenceManifestSchema } from '@/lib/api/contracts/workflow-references' +import { workflowStateSchema } from '@/lib/api/contracts/workflows' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' +import { + coerceObjectArray, + remapWorkflowReferencesInSubBlocks, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import { + applyDependentOverrides, + clearDependentsOnRemap, + remapForkBlockType, + remapSubBlocks, + replaceCustomBlockInputs, +} from '@/lib/workflows/references/remap-references' +import type { + PortableReference, + PortableResourceKind, + ReferenceOccurrence, + WorkflowReferenceManifest, +} from '@/lib/workflows/references/types' +import { normalizeImportedVariables } from '@/lib/workflows/variables/parse' +import { getBlock } from '@/blocks/registry' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +export interface ImportResourceMapping { + kind: PortableResourceKind + sourceId: string + targetId: string | null +} +export interface ImportFieldBinding extends ReferenceOccurrence { + kind: PortableResourceKind + targetId: string | null +} +export interface ImportDependentValue { + blockId: string + subBlockKey: string + value: string +} +export interface MappedImportOptions { + mappings?: ImportResourceMapping[] + bindings?: ImportFieldBinding[] + dependentValues?: ImportDependentValue[] +} +export interface ImportBindingResolution { + kind: PortableResourceKind + sourceId: string + targetId: string | null + required: boolean + occurrence: ReferenceOccurrence +} +export interface WorkflowImportPlan { + sourceState: WorkflowState + state: WorkflowState + manifest: WorkflowReferenceManifest + bindings: ImportBindingResolution[] + unresolvedBindings: ImportBindingResolution[] +} + +export function referenceOccurrenceKey( + occurrence: Pick +): string { + return JSON.stringify([occurrence.blockId, occurrence.subBlockKey, occurrence.valuePath]) +} + +function encodeIdentifiers(ids: string[], encoding: ReferenceOccurrence['encoding']): unknown { + if (encoding === 'files') return ids.map((key) => ({ key })) + if (encoding === 'array') return ids + if (encoding === 'csv') return ids.join(',') + if (ids.length > 1) + throw new OrchestrationError('validation', 'A scalar binding cannot contain multiple resources') + return ids[0] ?? '' +} + +/** Writes only registered locators into a clone; no caller-controlled object merge occurs. */ +function writeOccurrence( + blocks: Record, + occurrence: ReferenceOccurrence, + value: unknown +): void { + const block = Object.hasOwn(blocks, occurrence.blockId) ? blocks[occurrence.blockId] : undefined + if (!block) throw new OrchestrationError('validation', 'Reference block does not exist') + if (occurrence.subBlockKey === 'type' && occurrence.valuePath.length === 0) { + if (typeof value !== 'string') + throw new OrchestrationError('validation', 'Invalid custom block binding') + block.type = value + return + } + const field = Object.hasOwn(block.subBlocks, occurrence.subBlockKey) + ? block.subBlocks[occurrence.subBlockKey] + : undefined + if (!field) throw new OrchestrationError('validation', 'Reference field does not exist') + if (occurrence.valuePath.length === 0) { + field.value = value as BlockState['subBlocks'][string]['value'] + return + } + const { array, wasString } = coerceObjectArray(field.value) + if (!array) throw new OrchestrationError('validation', 'Reference tool collection does not exist') + let parent: unknown = array + for (const key of occurrence.valuePath.slice(0, -1)) { + if ( + ['__proto__', 'prototype', 'constructor'].includes(String(key)) || + (!isRecordLike(parent) && !Array.isArray(parent)) || + !Object.hasOwn(parent, key) + ) { + throw new OrchestrationError('validation', 'Invalid reference path') + } + parent = (parent as Record)[key] + } + const last = occurrence.valuePath.at(-1)! + if (['__proto__', 'prototype', 'constructor'].includes(String(last)) || !isRecordLike(parent)) { + throw new OrchestrationError('validation', 'Invalid reference field') + } + parent[last] = value + field.value = ( + wasString ? JSON.stringify(array) : array + ) as BlockState['subBlocks'][string]['value'] +} + +export function parsePortableWorkflow(workflow: string | Record): { + state: WorkflowState + manifest?: WorkflowReferenceManifest +} { + const text = typeof workflow === 'string' ? workflow : JSON.stringify(workflow) + if (Buffer.byteLength(text, 'utf8') > 10 * 1024 * 1024) + throw new OrchestrationError('payload_too_large', 'Workflow exceeds 10 MiB') + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + throw new OrchestrationError('validation', 'Workflow must contain valid JSON') + } + if (isRecordLike(payload) && isRecordLike(payload.data)) payload = payload.data + const parsed = parseWorkflowJson(text, false) + if (!parsed.data || parsed.errors.length) + throw new OrchestrationError('validation', `Invalid workflow: ${parsed.errors.join(', ')}`) + const state = { + ...parsed.data, + variables: normalizeImportedVariables( + parsed.data.variables, + (index) => `@import/variable/${index}` + ), + } + const variableEntries = Object.values(parsed.data.variables ?? {}).filter( + (value) => value && typeof value === 'object' + ) + if (Object.keys(state.variables).length !== variableEntries.length) + throw new OrchestrationError( + 'validation', + 'Imported variables contain duplicate source identifiers' + ) + if (Object.keys(state.variables).some((id) => Object.hasOwn(state.blocks, id))) + throw new OrchestrationError( + 'validation', + 'Imported block and variable identifiers must be distinct' + ) + const sourceIds = new Set([...Object.keys(state.blocks), ...Object.keys(state.variables)]) + for (const edge of state.edges) { + if (!edge.id) continue + if (sourceIds.has(edge.id)) + throw new OrchestrationError('validation', 'Imported graph identifiers must be distinct') + sourceIds.add(edge.id) + } + const validation = workflowStateSchema.safeParse(state) + if (!validation.success) + throw new OrchestrationError( + 'validation', + `Invalid workflow: ${validation.error.issues[0]?.message}` + ) + if (Object.keys(state.blocks).length > 2000 || state.edges.length > 10000) + throw new OrchestrationError('payload_too_large', 'Workflow exceeds the block or edge limit') + for (const [id, block] of Object.entries(state.blocks)) { + if (id !== block.id || ['__proto__', 'prototype', 'constructor'].includes(id)) + throw new OrchestrationError( + 'validation', + 'Block keys must match their source IDs and cannot use prototype names' + ) + } + const rawManifest = isRecordLike(payload) ? payload.referenceManifest : undefined + const manifest = + rawManifest === undefined ? undefined : workflowReferenceManifestSchema.parse(rawManifest) + return { state, manifest } +} + +/** Explicit mappings take precedence over inline code; otherwise the declaration is self-contained. */ +function prepareInlineDeclarations( + state: WorkflowState, + options: MappedImportOptions +): Set { + const inline = new Set() + for (const block of Object.values(state.blocks)) + for (const [key, field] of Object.entries(block.subBlocks)) { + if ( + !getBlock(block.type)?.subBlocks.some( + (definition) => definition.id === key && definition.type === 'tool-input' + ) + ) + continue + const { array, wasString } = coerceObjectArray(field.value) + if (!array) continue + array.forEach((tool, index) => { + if (!isRecordLike(tool) || tool.type !== 'custom-tool' || (!tool.code && !tool.schema)) + return + const occurrence = { + blockId: block.id, + subBlockKey: key, + valuePath: [index, 'customToolId'], + } + const explicit = + options.mappings?.some( + (mapping) => mapping.kind === 'custom-tool' && mapping.sourceId === tool.customToolId + ) || + options.bindings?.some( + (binding) => + binding.kind === 'custom-tool' && + referenceOccurrenceKey(binding) === referenceOccurrenceKey(occurrence) + ) + if (explicit) { + array[index] = omit(tool, ['code', 'schema']) + } else { + array[index] = omit(tool, ['customToolId', 'toolId']) + inline.add(referenceOccurrenceKey(occurrence)) + } + }) + field.value = (wasString ? JSON.stringify(array) : array) as typeof field.value + } + return inline +} + +/** Plans entirely from the supplied document; provenance never triggers a source lookup. */ +export function buildWorkflowImportPlan( + workflow: string | Record, + options: MappedImportOptions +): WorkflowImportPlan { + const parsed = parsePortableWorkflow(workflow) + const source = structuredClone(parsed.state) + const inline = prepareInlineDeclarations(source, options) + const manifest = parsed.manifest ?? buildWorkflowReferenceManifest(source.blocks) + const references: PortableReference[] = structuredClone(manifest.references) + .map((reference) => ({ + ...reference, + occurrences: reference.occurrences.filter( + (occurrence) => + reference.kind !== 'custom-tool' || !inline.has(referenceOccurrenceKey(occurrence)) + ), + })) + .filter((reference) => reference.occurrences.length) + for (const detected of buildWorkflowReferenceManifest(source.blocks).references) { + const existing = references.find( + (entry) => entry.kind === detected.kind && entry.sourceId === detected.sourceId + ) + if (!existing) references.push(detected) + else + for (const occurrence of detected.occurrences) { + if ( + !existing.occurrences.some( + (entry) => referenceOccurrenceKey(entry) === referenceOccurrenceKey(occurrence) + ) + ) + existing.occurrences.push(occurrence) + } + } + const explicit = new Map() + for (const binding of options.bindings ?? []) { + const key = referenceOccurrenceKey(binding) + if (explicit.has(key)) + throw new OrchestrationError('validation', 'A source field has duplicate bindings') + explicit.set(key, binding) + if ( + !references.some((reference) => + reference.occurrences.some((occurrence) => referenceOccurrenceKey(occurrence) === key) + ) + ) { + const { kind, targetId: _targetId, ...occurrence } = binding + references.push({ + kind, + sourceId: `legacy-binding-${references.length}`, + required: true, + occurrences: [occurrence], + }) + } + } + const groups = new Map< + string, + { occurrence: ReferenceOccurrence; references: PortableReference[] } + >() + for (const reference of references) + for (const occurrence of reference.occurrences) { + const key = referenceOccurrenceKey(occurrence) + const group = groups.get(key) ?? { occurrence, references: [] } + if (group.occurrence.encoding !== occurrence.encoding) + throw new OrchestrationError('validation', 'Conflicting reference encodings') + group.references.push({ ...reference, occurrences: [occurrence] }) + groups.set(key, group) + } + for (const { occurrence, references: entries } of groups.values()) { + if (occurrence.encoding !== 'environment') { + const ids: string[] = [] + entries.forEach((reference, index) => { + for (const position of reference.occurrences[0].positions ?? [index]) { + if (ids[position] && ids[position] !== reference.sourceId) + throw new OrchestrationError('validation', 'Conflicting resource positions') + ids[position] = reference.sourceId + } + }) + writeOccurrence( + source.blocks, + occurrence, + encodeIdentifiers( + ids.filter((id) => id !== undefined), + occurrence.encoding + ) + ) + } + } + const discovered = buildWorkflowReferenceManifest(source.blocks) + for (const reference of references) + for (const occurrence of reference.occurrences) { + if ( + !discovered.references.some( + (entry) => + entry.kind === reference.kind && + entry.sourceId === reference.sourceId && + entry.occurrences.some( + (actual) => referenceOccurrenceKey(actual) === referenceOccurrenceKey(occurrence) + ) + ) + ) { + throw new OrchestrationError( + 'validation', + 'Reference does not address a registered resource field' + ) + } + } + const mappings = new Map() + for (const mapping of options.mappings ?? []) { + const key = JSON.stringify([mapping.kind, mapping.sourceId]) + if (mappings.has(key)) throw new OrchestrationError('validation', 'Duplicate resource mapping') + if ( + !references.some( + (entry) => entry.kind === mapping.kind && entry.sourceId === mapping.sourceId + ) + ) + throw new OrchestrationError('validation', 'Mapping source is not referenced by the workflow') + mappings.set(key, mapping.targetId) + } + const bindings: ImportBindingResolution[] = [] + const state = structuredClone(source) + for (const { occurrence, references: entries } of groups.values()) { + entries.forEach((reference) => { + const key = JSON.stringify([reference.kind, reference.sourceId]) + const field = explicit.get(referenceOccurrenceKey(occurrence)) + if ( + field && + (field.kind !== reference.kind || + (mappings.has(key) && mappings.get(key) !== field.targetId)) + ) + throw new OrchestrationError( + 'validation', + 'Resource mapping conflicts with an explicit field binding' + ) + const targetId = field?.targetId ?? mappings.get(key) ?? null + const registered = discovered.references.find( + (entry) => entry.kind === reference.kind && entry.sourceId === reference.sourceId + )! + bindings.push({ + kind: reference.kind, + sourceId: reference.sourceId, + targetId, + required: registered.required, + occurrence, + }) + }) + } + const seenDependents = new Set() + for (const value of options.dependentValues ?? []) { + const key = JSON.stringify([value.blockId, value.subBlockKey]) + if (!Object.hasOwn(state.blocks, value.blockId) || seenDependents.has(key)) + throw new OrchestrationError('validation', 'Invalid or duplicate dependent field') + seenDependents.add(key) + } + for (const block of Object.values(state.blocks)) { + const fields: SubBlockRecord = {} + for (const [key, field] of Object.entries(block.subBlocks)) fields[key] = { ...field } + const blockBindings = bindings.filter((binding) => binding.occurrence.blockId === block.id) + const resolve = ( + kind: PortableResourceKind, + sourceId: string, + path: Array = ['type'] + ) => { + const binding = blockBindings.find( + (binding) => + binding.kind === kind && + binding.sourceId === sourceId && + JSON.stringify([binding.occurrence.subBlockKey, ...binding.occurrence.valuePath]) === + JSON.stringify(path) + ) + return binding?.targetId ?? null + } + const context = { + preserveToolIndices: true, + blockId: block.id, + blockType: block.type, + canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode, + } + const workflows = remapWorkflowReferencesInSubBlocks(fields, undefined, { + clearUnmapped: true, + preserveToolIndices: true, + canonicalModes: block.data?.canonicalModes, + resolve: (sourceId, path) => resolve('workflow', sourceId, path), + }) + const remapped = remapSubBlocks(workflows, resolve, context) + const cleared = clearDependentsOnRemap( + remapped.subBlocks, + block.type, + remapped.remappedKeys, + remapped.canonicalModes ?? block.data?.canonicalModes, + undefined, + block.triggerMode + ) + const dependentValues = new Map( + (options.dependentValues ?? []) + .filter((value) => value.blockId === block.id) + .map((value) => [value.subBlockKey, value.value]) + ) + const targetType = remapForkBlockType(block.type, resolve).type + const applied = + targetType !== block.type + ? replaceCustomBlockInputs(cleared, dependentValues, targetType) + : applyDependentOverrides(cleared, block.type, dependentValues) + block.type = targetType + if (remapped.canonicalModes) { + const canonicalModes: Record = {} + for (const [key, mode] of Object.entries(remapped.canonicalModes)) + if (mode) canonicalModes[key] = mode + block.data = { ...block.data, canonicalModes } + } + const next: BlockState['subBlocks'] = {} + for (const [key, field] of Object.entries(applied)) { + next[key] = { + id: key, + type: + typeof field.type === 'string' + ? (field.type as BlockState['subBlocks'][string]['type']) + : 'short-input', + value: field.value as BlockState['subBlocks'][string]['value'], + } + } + block.subBlocks = next + } + return { + sourceState: source, + state, + manifest: { version: 1, references }, + bindings, + unresolvedBindings: bindings.filter((binding) => binding.required && !binding.targetId), + } +} diff --git a/apps/sim/lib/workflows/references/inline-tools.ts b/apps/sim/lib/workflows/references/inline-tools.ts new file mode 100644 index 00000000000..07285cb1413 --- /dev/null +++ b/apps/sim/lib/workflows/references/inline-tools.ts @@ -0,0 +1,106 @@ +import { customTools } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { assertValidCustomToolDeclaration } from '@/lib/custom-tools/schema' +import type { DbOrTx } from '@/lib/db/types' +import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-ids' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +export interface ImportedInlineTool { + id: string + title: string + schema: Record + code: string +} + +/** Imported declarations are insert-only and receive new destination identities. */ +export function prepareImportedInlineTools(state: WorkflowState): ImportedInlineTool[] { + const byTitle = new Map() + for (const block of Object.values(state.blocks)) + for (const field of Object.values(block.subBlocks)) { + if (field.type !== 'tool-input') continue + const { array, wasString } = coerceObjectArray(field.value) + if (!array) continue + for (const tool of array) { + if (!isRecordLike(tool) || tool.type !== 'custom-tool' || (!tool.code && !tool.schema)) + continue + if ( + typeof tool.title !== 'string' || + !tool.title.trim() || + tool.title.length > 255 || + typeof tool.code !== 'string' || + tool.code.length > 1024 * 1024 || + !isRecordLike(tool.schema) + ) { + throw new OrchestrationError('validation', 'Invalid inline custom tool declaration') + } + assertValidCustomToolDeclaration(tool.schema) + const existing = byTitle.get(tool.title) + if ( + existing && + (existing.code !== tool.code || + JSON.stringify(existing.schema) !== JSON.stringify(tool.schema)) + ) { + throw new OrchestrationError( + 'validation', + 'Conflicting inline custom tools share a title' + ) + } + const imported = existing ?? { + id: generateId(), + title: tool.title, + schema: tool.schema, + code: tool.code, + } + byTitle.set(imported.title, imported) + tool.customToolId = imported.id + tool.toolId = imported.id + } + field.value = (wasString ? JSON.stringify(array) : array) as typeof field.value + } + if (byTitle.size > 2000) + throw new OrchestrationError('payload_too_large', 'Too many inline custom tools') + return [...byTitle.values()] +} + +export async function assertImportedInlineToolTitlesAvailable( + tx: DbOrTx, + workspaceId: string, + tools: ImportedInlineTool[] +): Promise { + if (!tools.length) return + const [duplicate] = await tx + .select({ title: customTools.title }) + .from(customTools) + .where( + and( + eq(customTools.workspaceId, workspaceId), + inArray( + customTools.title, + tools.map((tool) => tool.title) + ) + ) + ) + .limit(1) + if (duplicate) + throw new OrchestrationError( + 'conflict', + `An inline custom tool named "${duplicate.title}" already exists in the destination workspace` + ) +} + +export async function insertImportedInlineTools( + tx: DbOrTx, + workspaceId: string, + userId: string, + tools: ImportedInlineTool[] +): Promise { + await assertImportedInlineToolTitlesAvailable(tx, workspaceId, tools) + for (let offset = 0; offset < tools.length; offset += 100) { + await tx + .insert(customTools) + .values(tools.slice(offset, offset + 100).map((tool) => ({ ...tool, workspaceId, userId }))) + } +} diff --git a/apps/sim/lib/workflows/references/manifest.test.ts b/apps/sim/lib/workflows/references/manifest.test.ts new file mode 100644 index 00000000000..e62604685aa --- /dev/null +++ b/apps/sim/lib/workflows/references/manifest.test.ts @@ -0,0 +1,269 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +vi.mock('@/lib/workflows/search-replace/indexer', () => ({ + getToolInputParamConfigs: vi.fn(), +})) + +import { buildWorkflowImportPlan } from '@/lib/workflows/references/import-plan' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import { + applyDependentOverrides, + readTargetDraftDependentValue, + remapForkSubBlocks, +} from '@/lib/workflows/references/remap-references' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' +import { getBlock } from '@/blocks/registry' + +const configs: Record = { + agent: [{ id: 'tools', type: 'tool-input', title: 'Tools' }], + function: [ + { id: 'code', type: 'code', title: 'Code' }, + { id: 'files', type: 'file-upload', title: 'Files' }, + ], + logs: [ + { + id: 'workflowSelector', + type: 'dropdown', + title: 'Workflows', + selectorKey: 'sim.workflows', + multiSelect: true, + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + type: 'short-input', + title: 'Workflows', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ], +} + +function workflow(type: string, values: Record): WorkflowState { + return { + blocks: { + source: { + id: 'source', + type, + name: type, + enabled: true, + position: { x: 0, y: 0 }, + outputs: {}, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [ + id, + { + id, + type: configs[type].find((field) => field.id === id)!.type, + value: value as WorkflowState['blocks'][string]['subBlocks'][string]['value'], + }, + ]) + ), + }, + }, + edges: [], + loops: {}, + parallels: {}, + } +} + +beforeEach(() => { + vi.mocked(getBlock).mockImplementation((type) => + configs[type] ? ({ subBlocks: configs[type] } as BlockConfig) : undefined + ) + vi.mocked(getToolInputParamConfigs).mockImplementation(({ tool }) => + (configs[tool.type] ?? []).map((config) => ({ + paramId: config.id, + config, + authoritative: true, + value: tool.params?.[config.id], + })) + ) +}) + +describe('portable reference occurrence codecs', () => { + it.each([false, true])( + 'binds repeated nested environment names independently (serialized=%s)', + (serialized) => { + const tools = [ + { type: 'function', params: { code: 'return "{{SECRET}}"' } }, + { type: 'function', params: { code: 'return "{{SECRET}}"' } }, + ] + const source = workflow('agent', { tools: serialized ? JSON.stringify(tools) : tools }) + const manifest = buildWorkflowReferenceManifest(source.blocks) + expect(manifest.references).toHaveLength(1) + expect(manifest.references[0].occurrences.map((occurrence) => occurrence.valuePath)).toEqual([ + [0, 'params', 'code'], + [1, 'params', 'code'], + ]) + const exported = { + ...sanitizeForExport(source, { includeReferences: true }), + referenceManifest: manifest, + } + const plan = buildWorkflowImportPlan(exported, { + bindings: manifest.references[0].occurrences.map((occurrence, index) => ({ + ...occurrence, + kind: 'env-var', + targetId: index === 0 ? 'FIRST_SECRET' : 'SECOND_SECRET', + })), + }) + expect(plan.unresolvedBindings).toEqual([]) + const result = plan.state.blocks.source.subBlocks.tools.value + expect(typeof result === 'string' ? JSON.parse(result) : result).toEqual([ + { type: 'function', params: { code: 'return "{{FIRST_SECRET}}"' } }, + { type: 'function', params: { code: 'return "{{SECOND_SECRET}}"' } }, + ]) + } + ) + + it.each([false, true])( + 'keeps nested file occurrences and repeated positions (serialized=%s)', + (serialized) => { + const files = [{ key: 'source/file' }, { key: 'source/file' }] + const tools = [ + { type: 'function', params: { files } }, + { type: 'function', params: { files: JSON.stringify(files) } }, + ] + const source = workflow('agent', { tools: serialized ? JSON.stringify(tools) : tools }) + const manifest = buildWorkflowReferenceManifest(source.blocks) + expect( + manifest.references[0].occurrences.map(({ valuePath, positions }) => ({ + valuePath, + positions, + })) + ).toEqual([ + { valuePath: [0, 'params', 'files'], positions: [0, 1] }, + { valuePath: [1, 'params', 'files'], positions: [0, 1] }, + ]) + const plan = buildWorkflowImportPlan( + { ...sanitizeForExport(source, { includeReferences: true }), referenceManifest: manifest }, + { + mappings: [{ kind: 'file', sourceId: 'source/file', targetId: 'target/file' }], + } + ) + const result = plan.state.blocks.source.subBlocks.tools.value + expect(typeof result === 'string' ? JSON.parse(result) : result).toEqual([ + { type: 'function', params: { files: [{ key: 'target/file' }, { key: 'target/file' }] } }, + { type: 'function', params: { files: [{ key: 'target/file' }, { key: 'target/file' }] } }, + ]) + } + ) + + it.each(['workflow-a,workflow-b', ['workflow-a', 'workflow-b']])( + 'discovers registered workflow dropdown selections %j', + (value) => { + const source = workflow('logs', { workflowSelector: value }) + const manifest = buildWorkflowReferenceManifest(source.blocks) + expect(manifest.references.map((reference) => reference.sourceId)).toEqual([ + 'workflow-a', + 'workflow-b', + ]) + const plan = buildWorkflowImportPlan(source, { + mappings: manifest.references.map((reference) => ({ + kind: 'workflow', + sourceId: reference.sourceId, + targetId: `target-${reference.sourceId}`, + })), + }) + expect(plan.state.blocks.source.subBlocks.workflowSelector.value).toEqual( + Array.isArray(value) + ? ['target-workflow-a', 'target-workflow-b'] + : 'target-workflow-a,target-workflow-b' + ) + } + ) + + it('keeps manual workflow selectors outside resource mapping', () => { + const source = workflow('logs', { + workflowSelector: 'old-workflow', + manualWorkflowIds: 'manual-workflow', + }) + source.blocks.source.data = { canonicalModes: { workflowIds: 'advanced' } } + expect(buildWorkflowReferenceManifest(source.blocks).references).toEqual([]) + }) + + it('preserves own prototype-named data without exposing unsafe manifest locators', () => { + const source = workflow('function', { + code: JSON.parse('{"__proto__":{"polluted":"{{SECRET}}"},"safe":"{{SECRET}}"}'), + }) + const remapped = remapForkSubBlocks(source.blocks.source.subBlocks, () => 'TARGET', 'promote', { + blockType: 'function', + }) + const value = remapped.subBlocks.code.value as Record + expect(Object.getPrototypeOf(value)).toBe(Object.prototype) + expect(Object.hasOwn(value, '__proto__')).toBe(true) + expect(value.__proto__).toEqual({ polluted: '{{TARGET}}' }) + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false) + expect( + buildWorkflowReferenceManifest(source.blocks).references[0].occurrences.map( + (occurrence) => occurrence.valuePath + ) + ).toEqual([['safe']]) + }) + + it('applies legacy MCP tool-name overrides using the mapped server', () => { + const type = 'mcp' + const source = workflow('agent', { + tools: [ + { type, toolId: 'stale-tool', params: { serverId: 'target-server', toolName: null } }, + ], + }) + const result = applyDependentOverrides( + source.blocks.source.subBlocks, + 'agent', + new Map([ + ['tools[0].toolName', 'lookup'], + ['tools[0].serverId', 'forbidden-server'], + ]) + ) + expect(result.tools.value).toEqual([ + { + type, + toolId: 'mcp-target-server-lookup', + params: { serverId: 'target-server', toolName: 'lookup' }, + }, + ]) + }) + + it('does not add an individual tool selection to an advanced MCP server binding', () => { + const source = workflow('agent', { + tools: [{ type: 'mcp-server-advanced', params: { serverId: 'target-server' } }], + }) + const result = applyDependentOverrides( + source.blocks.source.subBlocks, + 'agent', + new Map([['tools[0].toolName', 'lookup']]) + ) + expect(result.tools.value).toEqual(source.blocks.source.subBlocks.tools.value) + }) + + it.each([{ value: ['first', 'second'] }, { value: [] }])( + 'reads string-array dependent selections at both field levels: $value', + ({ value }) => { + const fields = { + columns: { value }, + tools: { value: [{ type: 'function', params: { columns: value } }] }, + } + expect(readTargetDraftDependentValue(fields, fields, 'columns')).toBe(value.join(',')) + expect(readTargetDraftDependentValue(fields, fields, 'tools[0].columns')).toBe( + value.join(',') + ) + } + ) + + it('rejects mixed arrays and objects when reading dependent selections', () => { + for (const value of [['first', 1], { column: 'first' }]) { + const fields = { + columns: { value }, + tools: { value: [{ type: 'function', params: { columns: value } }] }, + } + expect(readTargetDraftDependentValue(fields, fields, 'columns')).toBe('') + expect(readTargetDraftDependentValue(fields, fields, 'tools[0].columns')).toBe('') + } + }) +}) diff --git a/apps/sim/lib/workflows/references/manifest.ts b/apps/sim/lib/workflows/references/manifest.ts new file mode 100644 index 00000000000..f2b6c04cbc9 --- /dev/null +++ b/apps/sim/lib/workflows/references/manifest.ts @@ -0,0 +1,181 @@ +import { isRecordLike } from '@sim/utils/object' +import { + coerceObjectArray, + type SubBlockRecord, +} from '@/lib/workflows/persistence/remap-internal-ids' +import { fileUploadReferencePositions } from '@/lib/workflows/references/remap-files' +import { + createCanonicalModeGates, + remapForkBlockType, + remapSubBlocks, +} from '@/lib/workflows/references/remap-references' +import type { + PortableResourceKind, + ReferenceOccurrence, + WorkflowReferenceManifest, +} from '@/lib/workflows/references/types' +import { buildSubBlockValues } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** Reads only an existing, explicitly addressed field; never follows prototype properties. */ +export function readReferenceValue(value: unknown, path: Array): unknown { + let current = value + for (const key of path) { + if (typeof current === 'string') { + const parsed = coerceObjectArray(current) + current = parsed.array + } + if ((!isRecordLike(current) && !Array.isArray(current)) || !Object.hasOwn(current, key)) { + return undefined + } + current = (current as Record)[key] + } + return current +} + +/** The manifest contains identifiers and locators only, never copied field values. */ +export function buildWorkflowReferenceManifest( + blocks: Record +): WorkflowReferenceManifest { + const references = new Map() + const record = ( + kind: PortableResourceKind, + sourceId: string, + required: boolean, + occurrence: ReferenceOccurrence + ) => { + if ( + !sourceId || + sourceId.length > 4096 || + /[\r\n?&#=]/.test(sourceId) || + sourceId.includes('://') || + [occurrence.blockId, occurrence.subBlockKey, ...occurrence.valuePath].some( + (key) => key === '__proto__' || key === 'prototype' || key === 'constructor' + ) + ) + return + const key = JSON.stringify([kind, sourceId]) + const entry = references.get(key) ?? { kind, sourceId, required, occurrences: [] } + if ( + !entry.occurrences.some((existing) => JSON.stringify(existing) === JSON.stringify(occurrence)) + ) { + entry.occurrences.push(occurrence) + } + references.set(key, entry) + } + for (const block of Object.values(blocks)) { + const custom = remapForkBlockType(block.type, (_kind, id) => id) + if (custom.reference) + record('custom-block', block.type, true, { + blockId: block.id, + subBlockKey: 'type', + valuePath: [], + encoding: 'scalar', + }) + const context = { + registeredReferencesOnly: true, + blockId: block.id, + blockType: block.type, + canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode, + } + const subBlocks: SubBlockRecord = {} + for (const [key, field] of Object.entries(block.subBlocks)) subBlocks[key] = { ...field } + const result = remapSubBlocks(subBlocks, (_kind, id) => id, context) + for (const reference of result.occurrences) { + const valuePath = reference.valuePath ?? [] + const value = readReferenceValue(block.subBlocks[reference.subBlockKey]?.value, valuePath) + const encoding = + reference.kind === 'env-var' + ? 'environment' + : reference.kind === 'file' + ? 'files' + : Array.isArray(value) + ? 'array' + : typeof value === 'string' && value.includes(',') + ? 'csv' + : 'scalar' + let values: unknown[] = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',').map((part) => part.trim()) + : [value] + if (reference.kind === 'file') { + const decoded = coerceObjectArray(value).array + values = (decoded ?? (Array.isArray(value) ? value : [value])).map((file) => + isRecordLike(file) ? (file.key ?? file.path ?? file.name) : undefined + ) + } + const positions = + reference.kind === 'file' + ? fileUploadReferencePositions(value, reference.sourceId) + : values.flatMap((entry, index) => (entry === reference.sourceId ? [index] : [])) + record(reference.kind, reference.sourceId, reference.required, { + blockId: block.id, + subBlockKey: reference.subBlockKey, + valuePath, + encoding, + positions, + }) + } + const gates = createCanonicalModeGates( + getBlock(block.type)?.subBlocks, + buildSubBlockValues(block.subBlocks), + block.data?.canonicalModes, + block.triggerMode === true + ) + for (const [key, field] of Object.entries(block.subBlocks)) { + const definition = getBlock(block.type)?.subBlocks.find( + (candidate) => candidate.id === key || candidate.canonicalParamId === key + ) + if (!definition) continue + if ( + gates.isDormantMember(key) || + gates.isConditionHidden(key) || + gates.isActiveManualMember(key) + ) + continue + if ( + definition.type === 'workflow-selector' || + definition.selectorKey === 'sim.workflows' || + (key === 'workflowIds' && definition.type === 'dropdown') + ) { + const ids = Array.isArray(field.value) + ? field.value + : typeof field.value === 'string' + ? field.value.split(',') + : [] + for (const id of ids) + if (typeof id === 'string' && id.trim() && !/[<>]/.test(id)) { + record('workflow', id.trim(), true, { + blockId: block.id, + subBlockKey: key, + valuePath: [], + positions: ids.flatMap((value, index) => (value === id ? [index] : [])), + encoding: Array.isArray(field.value) ? 'array' : ids.length > 1 ? 'csv' : 'scalar', + }) + } + } + if (definition.type === 'tool-input') { + const { array } = coerceObjectArray(field.value) + array?.forEach((tool, index) => { + if ( + isRecordLike(tool) && + tool.type === 'workflow_input' && + isRecordLike(tool.params) && + typeof tool.params.workflowId === 'string' + ) { + record('workflow', tool.params.workflowId, true, { + blockId: block.id, + subBlockKey: key, + valuePath: [index, 'params', 'workflowId'], + encoding: 'scalar', + }) + } + }) + } + } + } + return { version: 1, references: [...references.values()] } +} diff --git a/apps/sim/lib/workflows/references/preview-limits.test.ts b/apps/sim/lib/workflows/references/preview-limits.test.ts new file mode 100644 index 00000000000..646deb7a551 --- /dev/null +++ b/apps/sim/lib/workflows/references/preview-limits.test.ts @@ -0,0 +1,21 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { assertWorkflowPreviewFits } from '@/lib/workflows/references/preview-limits' + +describe('expanded workflow preview bounds', () => { + it('rejects an expanded field collection before response validation', () => { + expect(() => assertWorkflowPreviewFits({ configuration: Array(10001).fill({}) })).toThrow( + 'configuration exceeds 10000' + ) + }) + + it('bounds aggregate UTF-8 bytes even when every collection fits its item limit', () => { + const value = '😀'.repeat(1024) + expect(() => assertWorkflowPreviewFits({ configuration: Array(3000).fill({ value }) })).toThrow( + '10 MiB' + ) + expect(() => + assertWorkflowPreviewFits({ configuration: Array(1000).fill({ value }) }) + ).not.toThrow() + }) +}) diff --git a/apps/sim/lib/workflows/references/preview-limits.ts b/apps/sim/lib/workflows/references/preview-limits.ts new file mode 100644 index 00000000000..b715dd9c89b --- /dev/null +++ b/apps/sim/lib/workflows/references/preview-limits.ts @@ -0,0 +1,13 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Bounds expanded configuration reports as well as the workflow JSON that produced them. */ +export function assertWorkflowPreviewFits(preview: Record): void { + for (const [field, value] of Object.entries(preview)) + if (Array.isArray(value) && value.length > 10000) + throw new OrchestrationError( + 'payload_too_large', + `Workflow preview ${field} exceeds 10000 entries` + ) + if (Buffer.byteLength(JSON.stringify(preview), 'utf8') > 10 * 1024 * 1024) + throw new OrchestrationError('payload_too_large', 'Workflow preview exceeds 10 MiB') +} diff --git a/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts b/apps/sim/lib/workflows/references/reference-scan.ts similarity index 97% rename from apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts rename to apps/sim/lib/workflows/references/reference-scan.ts index ec82b5f7d17..9e97e3a15b7 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts +++ b/apps/sim/lib/workflows/references/reference-scan.ts @@ -1,8 +1,8 @@ -import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { type ForkRemapKind, scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import type { WorkflowState } from '@/stores/workflows/workflow/types' /** A block reduced to what the reference scanner reads (incl. canonical context for detection). */ diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-files.test.ts b/apps/sim/lib/workflows/references/remap-files.test.ts similarity index 96% rename from apps/sim/ee/workspace-forking/lib/remap/remap-files.test.ts rename to apps/sim/lib/workflows/references/remap-files.test.ts index 93a55edc3b7..f5816a1e4fc 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-files.test.ts +++ b/apps/sim/lib/workflows/references/remap-files.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { remapForkFileUploadValue } from '@/ee/workspace-forking/lib/remap/remap-files' +import { remapForkFileUploadValue } from '@/lib/workflows/references/remap-files' const map = (entries: Record) => (key: string) => entries[key] ?? null diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-files.ts b/apps/sim/lib/workflows/references/remap-files.ts similarity index 90% rename from apps/sim/ee/workspace-forking/lib/remap/remap-files.ts rename to apps/sim/lib/workflows/references/remap-files.ts index a0442c352d0..2431752c5b1 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-files.ts +++ b/apps/sim/lib/workflows/references/remap-files.ts @@ -36,6 +36,13 @@ function fileItemKeyField(item: unknown): { field: 'key' | 'path' | 'name'; key: return null } +/** Preserves every file occurrence, including repeated keys and serialized single objects. */ +export function fileUploadReferencePositions(value: unknown, key: string): number[] { + const parsed = parseMaybeJson(value).value + const items = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [] + return items.flatMap((item, index) => (fileItemKeyField(item)?.key === key ? [index] : [])) +} + /** * Enumerate the workspace-file storage keys referenced by a `file-upload` subblock * value (single object, array, or JSON-string form). Used at promote time to emit each diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts b/apps/sim/lib/workflows/references/remap-references.test.ts similarity index 99% rename from apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts rename to apps/sim/lib/workflows/references/remap-references.test.ts index 518f2fcc17d..f834126a8fd 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts +++ b/apps/sim/lib/workflows/references/remap-references.test.ts @@ -28,8 +28,6 @@ vi.mock('@/tools/params', () => ({ })) import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' -import { getBlock } from '@/blocks/registry' -import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' import { applyDependentOverrides, clearDependentsOnRemap, @@ -42,7 +40,9 @@ import { remapForkSubBlocks, remapToolBlockResources, scanWorkflowReferences, -} from '@/ee/workspace-forking/lib/remap/remap-references' +} from '@/lib/workflows/references/remap-references' +import { getBlock } from '@/blocks/registry' +import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap' const blockConfigs: Record = { testblock: { diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/lib/workflows/references/remap-references.ts similarity index 89% rename from apps/sim/ee/workspace-forking/lib/remap/remap-references.ts rename to apps/sim/lib/workflows/references/remap-references.ts index 51a83a6570f..3831c89af78 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/lib/workflows/references/remap-references.ts @@ -3,15 +3,18 @@ import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike, omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import { isWorkflowAnnotationOnlyBlockType } from '@sim/workflow-types/workflow' -import type { z } from 'zod' -import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' -import { readFolderPaths, replaceFolderPath } from '@/lib/folders/selection' +import { readFolderPaths } from '@/lib/folders/selection' import { createMcpToolId, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { coerceObjectArray, type SubBlockRecord, } from '@/lib/workflows/persistence/remap-internal-ids' import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' +import { + collectForkFileUploadKeys, + remapForkFileUploadValue, +} from '@/lib/workflows/references/remap-files' +import type { WorkflowResourceKind } from '@/lib/workflows/references/types' import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { getWorkflowSearchSubBlockResourceDefinition, @@ -44,10 +47,6 @@ import type { ParsedStoredTool } from '@/lib/workflows/tool-input/types' import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' -import { - collectForkFileUploadKeys, - remapForkFileUploadValue, -} from '@/ee/workspace-forking/lib/remap/remap-files' import { isEnvVarReference, isReference } from '@/executor/constants' import type { ParameterVisibility } from '@/tools/types' @@ -58,7 +57,7 @@ import type { ParameterVisibility } from '@/tools/types' * excluded: workflow references are remapped via the workflow identity map, and * MCP tool / selector ids are not workspace-local so they carry over unchanged. */ -export type ForkRemapKind = z.infer +export type ForkRemapKind = WorkflowResourceKind const logger = createLogger('WorkspaceForkRemapReferences') @@ -67,7 +66,12 @@ const logger = createLogger('WorkspaceForkRemapReferences') * mapping), as opposed to optional kinds that silently clear. Exported so the cleared-ref preview * can exclude them - a required ref is a blocker, never a silent "will be cleared" item. */ -export const REQUIRED_KINDS = new Set(['credential', 'env-var', 'file-folder']) +export const REQUIRED_KINDS = new Set([ + 'credential', + 'env-var', + 'file-folder', + 'sandbox', +]) /** * Id-based override kind for a TOOL param's credential, resolved by subblock id so a @@ -195,7 +199,8 @@ export function rewriteEnvRefsInText( */ export type ForkReferenceResolver = ( kind: ForkRemapKind, - sourceId: string + sourceId: string, + valuePath?: Array ) => string | null | undefined /** Identity metadata of a mapped TARGET MCP server row (url is null for url-less transports). */ @@ -221,11 +226,15 @@ export interface ForkReference { blockName?: string subBlockKey: string required: boolean + /** Path within a structured subblock value, including nested Agent tool params. */ + valuePath?: Array } export interface RemapSubBlocksResult { subBlocks: SubBlockRecord references: ForkReference[] + /** Every field occurrence, before resource-level deduplication. */ + occurrences: ForkReference[] unmapped: ForkReference[] /** Subblock keys whose resource id was rewritten/cleared this pass (the `dependsOn` parents). */ remappedKeys: Set @@ -257,7 +266,9 @@ export type SubBlockTransform = ( canonicalModes?: CanonicalModeOverrides, onCanonicalModesChanged?: (next: CanonicalModeOverrides) => void, /** The block's trigger mode, scoping the canonical index (see {@link createCanonicalModeGates}). */ - triggerMode?: boolean + triggerMode?: boolean, + /** Keeps source tool indices until dependent overrides have applied. */ + preserveToolIndices?: boolean ) => SubBlockRecord /** @@ -579,6 +590,8 @@ export function createCanonicalModeGates( /** Per-block context for the fork remap. `blockType`/`canonicalModes` gate DETECTION (not rewrite). */ export interface RemapForkContext { + preserveToolIndices?: boolean + registeredReferencesOnly?: boolean blockId?: string blockName?: string /** @@ -604,46 +617,84 @@ export interface RemapForkContext { isCopiedTarget?: (kind: ForkRemapKind, sourceId: string) => boolean } +/** Rewrites original folder tokens once, including serialized picker arrays. */ +function remapFolderPaths(value: unknown, replacements: ReadonlyMap): unknown { + if (typeof value === 'string' && value.trim().startsWith('[')) { + try { + return JSON.stringify(remapFolderPaths(JSON.parse(value), replacements)) + } catch { + return value + } + } + if (Array.isArray(value)) { + return value.flatMap((entry) => { + if (typeof entry !== 'string') return [entry] + const target = replacements.get(entry.trim()) + return target === undefined ? [entry] : target ? [target] : [] + }) + } + if (typeof value === 'string') + return value + .split(',') + .map((entry) => replacements.get(entry.trim()) ?? entry) + .filter(Boolean) + .join(',') + return value +} + function remapEnvInValue( value: unknown, resolve: ForkReferenceResolver, - record: (sourceId: string, mapped: boolean) => void + record: (sourceId: string, mapped: boolean, path: Array) => void, + path: Array = [] ): unknown { if (typeof value === 'string') { return value.replace(ENV_REF_PATTERN, (full, key: string) => { - const target = resolve('env-var', key) + const target = resolve('env-var', key, path) if (target == null) { - record(key, false) + record(key, false, path) return full } - record(key, true) + record(key, true, path) return `{{${target}}}` }) } if (Array.isArray(value)) { - return value.map((item) => remapEnvInValue(item, resolve, record)) + let changed = false + const next = value.map((item, index) => { + const remapped = remapEnvInValue(item, resolve, record, [...path, index]) + if (remapped !== item) changed = true + return remapped + }) + return changed ? next : value } // Recurse plain objects so `{{ENV}}` nested in array-form tool params (and other // object-valued subblocks) is rewritten, not just top-level strings/arrays. if (isRecordLike(value)) { let changed = false - const next: Record = {} + const next: Record = Object.create(null) for (const [key, nested] of Object.entries(value)) { - const remapped = remapEnvInValue(nested, resolve, record) + const remapped = remapEnvInValue(nested, resolve, record, [...path, key]) if (remapped !== nested) changed = true next[key] = remapped } - return changed ? next : value + return changed ? { ...next } : value } return value } interface ToolBlockRemapOptions { + registeredReferencesOnly?: boolean resolve: ForkReferenceResolver /** Resolve a copied file storage key; null when the file was not copied. */ - resolveFileKey: (sourceKey: string) => string | null + resolveFileKey: (sourceKey: string, paramKey: string) => string | null /** Record a detected reference so it surfaces in the mapping UI / cascade. */ - record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void + record?: ( + kind: ForkRemapKind, + sourceId: string, + mapped: boolean, + valuePath?: Array + ) => void /** Fork-create clears unresolved copyable refs; promote keeps them (surfaced as unmapped). */ clearUnresolved: boolean /** Injected block configs (production falls back to the block registry). */ @@ -718,11 +769,18 @@ export function remapToolBlockResources( for (const paramId of Object.keys(params)) { const overrideKind = getToolParamOverrideKind(paramId) if (!overrideKind) continue + if ( + opts.registeredReferencesOnly && + !toolBlockSubBlocks?.some( + (config) => config.id === paramId || config.canonicalParamId === paramId + ) + ) + continue if (gates.isDormantMember(paramId)) continue const currentValue = params[paramId] if (typeof currentValue !== 'string' || !currentValue) continue - const target = opts.resolve(overrideKind, currentValue) - opts.record?.(overrideKind, currentValue, target != null) + const target = opts.resolve(overrideKind, currentValue, ['params', paramId]) + opts.record?.(overrideKind, currentValue, target != null, ['params', paramId]) if (target != null) { if (target !== currentValue) { setParam(paramId, target) @@ -766,9 +824,21 @@ export function remapToolBlockResources( /** Subblock ids remapped via a COPY, so copy-faithful dependents (column picks) survive. */ const copyRemappedSubBlockIds = new Set() - for (const { paramId, config } of configs) { + for (const { paramId, config, authoritative } of configs) { + if (opts.registeredReferencesOnly && !authoritative) continue if (getToolParamOverrideKind(paramId)) continue const definition = getWorkflowSearchSubBlockResourceDefinition(config) + if (config.selectorKey === 'workspace.sandboxes' && !gates.isDormantMember(paramId)) { + const value = params[paramId] + if (typeof value === 'string' && value && !isReference(value) && !isEnvVarReference(value)) { + const target = opts.resolve('sandbox', value, ['params', paramId]) + opts.record?.('sandbox', value, target != null, ['params', paramId]) + if (target !== value) { + setParam(paramId, target ?? '') + remappedParamIds.add(paramId) + } + } + } if (!definition) continue // Belt-and-braces: the params helper already returns only each pair's ACTIVE member, but a // dormant member slipping through must never remap - in advanced mode the shared @@ -794,9 +864,14 @@ export function remapToolBlockResources( // a nested tool's workspace file surfaces in the scan / unmapped set and can be copied. if (config.type !== 'file-upload') continue for (const fileKey of collectForkFileUploadKeys(currentValue)) { - opts.record?.('file', fileKey, opts.resolveFileKey(fileKey) != null) + opts.record?.('file', fileKey, opts.resolveFileKey(fileKey, paramKey) != null, [ + 'params', + paramKey, + ]) } - const remapped = remapForkFileUploadValue(currentValue, opts.resolveFileKey) + const remapped = remapForkFileUploadValue(currentValue, (key) => + opts.resolveFileKey(key, paramKey) + ) if (remapped !== currentValue) { setParam(paramKey, remapped) remappedParamIds.add(paramKey) @@ -814,46 +889,26 @@ export function remapToolBlockResources( : parseWorkflowSearchSubBlockResources(currentValue, config) if (refs.length === 0) continue - let value: unknown = currentValue - const seen = new Set() + const replacements = new Map() for (const ref of refs) { - if (seen.has(ref.rawValue)) continue - seen.add(ref.rawValue) - // A canonical param key is also the advanced (manual) member's write target, so it can - // hold user-owned references (`` / `{{ENV}}`). Those are never workspace ids: - // keep them verbatim (the manual escape hatch), don't record or clear them. + if (replacements.has(ref.rawValue)) continue if (isReference(ref.rawValue) || isEnvVarReference(ref.rawValue)) continue - const target = opts.resolve(forkKind, ref.rawValue) - const mapped = target != null - opts.record?.(forkKind, ref.rawValue, mapped) - if (mapped) { - if (target !== ref.rawValue) { - if (forkKind === 'file-folder') { - value = replaceFolderPath(value, ref.rawValue, target) - if (opts.isCopiedTarget?.(forkKind, ref.rawValue)) { - copyRemappedSubBlockIds.add(paramId) - } - } else { - const replaced = definition.codec.replace(value, ref.rawValue, target) - if (replaced.success) { - value = replaced.nextValue - if (opts.isCopiedTarget?.(forkKind, ref.rawValue)) { - copyRemappedSubBlockIds.add(paramId) - } - } - } - } - } else if (opts.clearUnresolved) { - // Drop only this unresolved entry (blank it - empties are filtered at parse - // time), so a mixed copied/uncopied multi-value field keeps its copied refs. - if (forkKind === 'file-folder') { - value = replaceFolderPath(value, ref.rawValue, '') - } else { - const replaced = definition.codec.replace(value, ref.rawValue, '') - if (replaced.success) value = replaced.nextValue - } + const target = opts.resolve(forkKind, ref.rawValue, ['params', paramKey]) + opts.record?.(forkKind, ref.rawValue, target != null, ['params', paramKey]) + if (target != null || opts.clearUnresolved) replacements.set(ref.rawValue, target ?? '') + if ( + target != null && + target !== ref.rawValue && + opts.isCopiedTarget?.(forkKind, ref.rawValue) + ) { + copyRemappedSubBlockIds.add(paramId) } } + const value = + forkKind === 'file-folder' + ? remapFolderPaths(currentValue, replacements) + : (definition.codec.remap?.(currentValue, (id) => replacements.get(id) ?? id) ?? + currentValue) if (value !== currentValue) { setParam(paramKey, value) @@ -931,9 +986,16 @@ export function remapToolBlockResources( } interface ForkToolInputOptions { + preserveToolIndices?: boolean + registeredReferencesOnly?: boolean /** Fork-create drops unresolved tools / clears params; promote keeps + records. */ clearUnresolved: boolean - record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void + record?: ( + kind: ForkRemapKind, + sourceId: string, + mapped: boolean, + valuePath?: Array + ) => void /** Target MCP server row lookup for rewriting a remapped MCP entry's server metadata. */ resolveMcpServerMeta?: ForkMcpServerMetaResolver /** Copy provenance for a resolved target (see {@link RemapForkContext.isCopiedTarget}). */ @@ -978,8 +1040,8 @@ function remapForkToolInputValue( return } if (tool.type === 'custom-tool' && typeof tool.customToolId === 'string') { - const target = resolve('custom-tool', tool.customToolId) - opts.record?.('custom-tool', tool.customToolId, target != null) + const target = resolve('custom-tool', tool.customToolId, [toolIndex, 'customToolId']) + opts.record?.('custom-tool', tool.customToolId, target != null, [toolIndex, 'customToolId']) if (target != null) { if (target !== tool.customToolId) { changed = true @@ -991,6 +1053,7 @@ function remapForkToolInputValue( } if (opts.clearUnresolved) { changed = true + if (opts.preserveToolIndices) keep({ ...tool, customToolId: '' }) return // Dropped - later tools shift down. } keep(tool) @@ -1002,8 +1065,8 @@ function remapForkToolInputValue( typeof tool.params.serverId === 'string' ) { const serverId = tool.params.serverId - const target = resolve('mcp-server', serverId) - opts.record?.('mcp-server', serverId, target != null) + const target = resolve('mcp-server', serverId, [toolIndex, 'params', 'serverId']) + opts.record?.('mcp-server', serverId, target != null, [toolIndex, 'params', 'serverId']) if (target != null) { if (target !== serverId) { changed = true @@ -1034,18 +1097,22 @@ function remapForkToolInputValue( } if (opts.clearUnresolved) { changed = true + if (opts.preserveToolIndices) keep({ ...tool, params: { ...tool.params, serverId: '' } }) return // Dropped - later tools shift down. } keep(tool) return } const remapped = remapToolBlockResources(tool, { - resolve, - resolveFileKey: (key) => resolve('file', key) ?? null, - record: opts.record, + resolve: (kind, id, path) => resolve(kind, id, [toolIndex, ...(path ?? [])]), + resolveFileKey: (key, paramKey) => + resolve('file', key, [toolIndex, 'params', paramKey]) ?? null, + record: (kind, sourceId, mapped, path) => + opts.record?.(kind, sourceId, mapped, [toolIndex, ...(path ?? [])]), clearUnresolved: opts.clearUnresolved, isCopiedTarget: opts.isCopiedTarget, parentCanonicalModes: opts.parentCanonicalModes, + registeredReferencesOnly: opts.registeredReferencesOnly, toolIndex, }) if (remapped !== tool) changed = true @@ -1073,11 +1140,11 @@ function remapForkSkillInputValue( const { array, wasString } = coerceObjectArray(value) if (!array) return value let changed = false - const next = array.flatMap((entry) => { + const next = array.flatMap((entry, index) => { if (!isRecordLike(entry) || typeof entry.skillId !== 'string') return [entry] if (entry.skillId.startsWith('builtin-')) return [entry] - const target = resolve('skill', entry.skillId) - opts.record?.('skill', entry.skillId, target != null) + const target = resolve('skill', entry.skillId, [index, 'skillId']) + opts.record?.('skill', entry.skillId, target != null, [index, 'skillId']) if (target != null) { if (target !== entry.skillId) { changed = true @@ -1110,6 +1177,7 @@ export function remapForkSubBlocks( ): RemapSubBlocksResult { const clearUnresolved = true const result: SubBlockRecord = {} + const occurrences: ForkReference[] = [] const references = new Map() const unmapped = new Map() const remappedKeys = new Set() @@ -1121,6 +1189,7 @@ export function remapForkSubBlocks( const recordReference = (key: string, reference: ForkReference, mapped: boolean) => { if (mode !== 'promote') return + occurrences.push(reference) references.set(key, reference) if (!mapped) unmapped.set(key, reference) } @@ -1159,6 +1228,8 @@ export function remapForkSubBlocks( continue } + const resolveField: ForkReferenceResolver = (kind, id, path) => + resolve(kind, id, [subBlockKey, ...(path ?? [])]) let value = subBlock.value const valueBeforeResource = value const subBlockType = typeof subBlock.type === 'string' ? subBlock.type : undefined @@ -1182,8 +1253,15 @@ export function remapForkSubBlocks( const verbatimManual = !dormant && (gates.isActiveManualMember(subBlockKey) || gates.isManualParentDependent(subBlockKey)) + const unregistered = + context?.registeredReferencesOnly === true && + !configByBaseKey.has(subBlockKey.replace(/_\d+$/, '')) const detectionSkipped = - annotationOnly || dormant || verbatimManual || gates.isConditionHidden(subBlockKey) + unregistered || + annotationOnly || + dormant || + verbatimManual || + gates.isConditionHidden(subBlockKey) // `{{ENV}}` detection is gated on EXECUTION, not on ownership. A dormant member and a // condition-hidden field never execute, so their refs must not become sync blockers - but an // ACTIVE MANUAL member is exactly the value that DOES execute, and its `{{KEY}}` is a live @@ -1193,62 +1271,70 @@ export function remapForkSubBlocks( // missing that secret silently passed the required-env gate instead of blocking the sync. // Resource-id detection keeps `verbatimManual` (a hand-typed id stays a user-owned escape // hatch); only env refs, which are never workspace-scoped ids, are detected here. - const envDetectionSkipped = annotationOnly || dormant || gates.isConditionHidden(subBlockKey) + const envDetectionSkipped = + unregistered || annotationOnly || dormant || gates.isConditionHidden(subBlockKey) if (dormant && isNonEmptyValue(value)) { value = '' } + if ( + config?.selectorKey === 'workspace.sandboxes' && + typeof value === 'string' && + value && + !isReference(value) && + !isEnvVarReference(value) && + !verbatimManual + ) { + const target = resolveField('sandbox', value) + if (!detectionSkipped) + recordReference( + `sandbox:${value}`, + { + kind: 'sandbox', + sourceId: value, + blockId: context?.blockId, + blockName: context?.blockName, + subBlockKey, + required: true, + }, + target != null + ) + value = target ?? '' + } + if (definition && forkKind && subBlockType && !verbatimManual) { const parsed = forkKind === 'file-folder' ? readFolderPaths(value).map((rawValue) => ({ rawValue })) : parseWorkflowSearchSubBlockResources(value, config) - const seen = new Set() + const replacements = new Map() for (const ref of parsed) { - if (seen.has(ref.rawValue)) continue - seen.add(ref.rawValue) + if (replacements.has(ref.rawValue)) continue if (isReference(ref.rawValue) || isEnvVarReference(ref.rawValue)) continue - const required = REQUIRED_KINDS.has(forkKind) - const reference: ForkReference = { - kind: forkKind, - sourceId: ref.rawValue, - blockId: context?.blockId, - blockName: context?.blockName, - subBlockKey, - required, - } - const target = resolve(forkKind, ref.rawValue) - const mapped = target != null - if (!detectionSkipped) recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped) - if (mapped) { - if (target !== ref.rawValue) { - if (forkKind === 'mcp-server') mcpServerRemaps.set(ref.rawValue, target) - if (forkKind === 'file-folder') { - value = replaceFolderPath(value, ref.rawValue, target) - if (context?.isCopiedTarget?.(forkKind, ref.rawValue)) { - copyRemappedKeys.add(subBlockKey) - } - } else { - const replaceResult = definition.codec.replace(value, ref.rawValue, target) - if (replaceResult.success) { - value = replaceResult.nextValue - if (context?.isCopiedTarget?.(forkKind, ref.rawValue)) { - copyRemappedKeys.add(subBlockKey) - } - } - } - } - } else if (clearUnresolved) { - // Drop only this unresolved entry (blank it - empties are filtered at - // parse time) so a mixed copied/uncopied multi-value field keeps its rest. - if (forkKind === 'file-folder') { - value = replaceFolderPath(value, ref.rawValue, '') - } else { - const replaceResult = definition.codec.replace(value, ref.rawValue, '') - if (replaceResult.success) value = replaceResult.nextValue - } + const target = resolveField(forkKind, ref.rawValue) + if (!detectionSkipped) + recordReference( + `${forkKind}:${ref.rawValue}`, + { + kind: forkKind, + sourceId: ref.rawValue, + blockId: context?.blockId, + blockName: context?.blockName, + subBlockKey, + required: REQUIRED_KINDS.has(forkKind), + }, + target != null + ) + if (target != null || clearUnresolved) replacements.set(ref.rawValue, target ?? '') + if (target != null && target !== ref.rawValue) { + if (forkKind === 'mcp-server') mcpServerRemaps.set(ref.rawValue, target) + if (context?.isCopiedTarget?.(forkKind, ref.rawValue)) copyRemappedKeys.add(subBlockKey) } } + value = + forkKind === 'file-folder' + ? remapFolderPaths(value, replacements) + : (definition.codec.remap?.(value, (id) => replacements.get(id) ?? id) ?? value) } if (subBlockType === 'file-upload') { @@ -1269,12 +1355,20 @@ export function remapForkSubBlocks( subBlockKey, required: false, }, - resolve('file', fileKey) != null + resolveField('file', fileKey) != null ) } - value = remapForkFileUploadValue(value, (sourceKey) => resolve('file', sourceKey) ?? null) + value = remapForkFileUploadValue( + value, + (sourceKey) => resolveField('file', sourceKey) ?? null + ) } else if (subBlockType === 'tool-input' || subBlockType === 'skill-input') { - const record = (kind: ForkRemapKind, sourceId: string, mapped: boolean) => { + const record = ( + kind: ForkRemapKind, + sourceId: string, + mapped: boolean, + valuePath?: Array + ) => { if (detectionSkipped) return recordReference( `${kind}:${sourceId}`, @@ -1285,15 +1379,18 @@ export function remapForkSubBlocks( blockName: context?.blockName, subBlockKey, required: REQUIRED_KINDS.has(kind), + valuePath, }, mapped ) } if (subBlockType === 'tool-input') { - const toolInputResult = remapForkToolInputValue(value, resolve, { + const toolInputResult = remapForkToolInputValue(value, resolveField, { clearUnresolved, record, resolveMcpServerMeta: context?.resolveMcpServerMeta, + registeredReferencesOnly: context?.registeredReferencesOnly, + preserveToolIndices: context?.preserveToolIndices, isCopiedTarget: context?.isCopiedTarget, // Build on any reindex from an earlier `tool-input` subblock on this same block // (rare - most blocks have one), so multiple fields don't clobber each other. @@ -1302,7 +1399,7 @@ export function remapForkSubBlocks( value = toolInputResult.value if (toolInputResult.canonicalModes) reindexedCanonicalModes = toolInputResult.canonicalModes } else { - value = remapForkSkillInputValue(value, resolve, { clearUnresolved, record }) + value = remapForkSkillInputValue(value, resolveField, { clearUnresolved, record }) } } @@ -1313,7 +1410,9 @@ export function remapForkSubBlocks( // never executes, so it must not become a required sync blocker. An ACTIVE MANUAL member's // ref IS recorded (see {@link envDetectionSkipped}) - it executes, so it must gate the sync. if (mode === 'promote') { - value = remapEnvInValue(value, resolve, (sourceId, mapped) => { + const serializedTools = subBlockType === 'tool-input' ? coerceObjectArray(value) : null + const envValue = serializedTools?.wasString ? serializedTools.array : value + const remappedEnvValue = remapEnvInValue(envValue, resolveField, (sourceId, mapped, path) => { if (envDetectionSkipped) return recordReference( `env-var:${sourceId}`, @@ -1324,10 +1423,17 @@ export function remapForkSubBlocks( blockName: context?.blockName, subBlockKey, required: true, + ...(path.length ? { valuePath: path } : {}), }, mapped ) }) + value = + serializedTools?.wasString && remappedEnvValue !== envValue + ? JSON.stringify(remappedEnvValue) + : serializedTools?.wasString + ? value + : remappedEnvValue } result[subBlockKey] = { ...subBlock, value } @@ -1362,6 +1468,7 @@ export function remapForkSubBlocks( return { subBlocks: result, references: Array.from(references.values()), + occurrences, unmapped: Array.from(unmapped.values()), remappedKeys, copyRemappedKeys, @@ -1707,6 +1814,12 @@ export function readTargetDraftDependentValue( subBlockKey: string ): string { if (!targetDraftSubBlocks) return '' + const selectionValue = (value: unknown): string => + typeof value === 'string' + ? value + : Array.isArray(value) && value.every((item) => typeof item === 'string') + ? value.join(',') + : '' const nested = parseNestedDependentKey(subBlockKey) if (nested) { const { toolInputId, index, paramId } = nested @@ -1716,14 +1829,14 @@ export function readTargetDraftDependentValue( if (!isRecordLike(sourceTool) || sourceTool.type !== targetTool.type) return '' const params = isRecordLike(targetTool.params) ? targetTool.params : {} const value = params[paramId] - return typeof value === 'string' ? value : '' + return selectionValue(value) } // TODO(fork): identity-guard top-level reads too - only seed when the target draft's parent // (credential/KB/table) still equals the mapped target. Threading the parent subblock id and // mapped target value here is invasive, and a changed parent is already blanked by the modal's // `parentChanged` logic, leaving only a narrow same-index/different-parent first-sync edge. const value = targetDraftSubBlocks[subBlockKey]?.value - return typeof value === 'string' ? value : '' + return selectionValue(value) } /** @@ -1743,13 +1856,11 @@ function applyNestedToolOverrides( const forTool = items.filter((item) => item.index === index) if (forTool.length === 0) return tool if (!isRecordLike(tool) || typeof tool.type !== 'string') return tool + const isMcp = tool.type === 'mcp' const toolConfig = getBlock(tool.type) - if (!toolConfig) return tool - const allowed = new Set( - toolConfig.subBlocks - .filter((cfg) => cfg.id && cfg.dependsOn && cfg.selectorKey) - .map((cfg) => cfg.id) - ) + if (!toolConfig && !isMcp) return tool + const allowed = reconfigurableDependentIds(toolConfig?.subBlocks ?? []) + if (isMcp) allowed.add('toolName') const params = isRecordLike(tool.params) ? tool.params : {} let nextParams: Record | null = null for (const item of forTool) { @@ -1759,7 +1870,15 @@ function applyNestedToolOverrides( } if (!nextParams) return tool changed = true - return { ...tool, params: nextParams } + return { + ...tool, + params: nextParams, + ...(isMcp && + typeof nextParams.serverId === 'string' && + typeof nextParams.toolName === 'string' + ? { toolId: createMcpToolId(nextParams.serverId, nextParams.toolName) } + : {}), + } }) if (!changed) return value return wasString ? JSON.stringify(merged) : merged @@ -1884,11 +2003,19 @@ export function createForkSubBlockTransform( isCopiedTarget?: (kind: ForkRemapKind, sourceId: string) => boolean } ): SubBlockTransform { - return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => { + return ( + subBlocks, + blockType, + canonicalModes, + onCanonicalModesChanged, + triggerMode, + preserveToolIndices + ) => { const result = remapSubBlocks(subBlocks, resolve, { blockType, canonicalModes, triggerMode, + preserveToolIndices, resolveMcpServerMeta: options?.resolveMcpServerMeta, isCopiedTarget: options?.isCopiedTarget, }) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts b/apps/sim/lib/workflows/references/resources.ts similarity index 75% rename from apps/sim/ee/workspace-forking/lib/mapping/resources.ts rename to apps/sim/lib/workflows/references/resources.ts index 4b4c9291b91..f1168ae7d45 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/resources.ts +++ b/apps/sim/lib/workflows/references/resources.ts @@ -14,19 +14,16 @@ import { workspace, workspaceEnvironment, workspaceFiles, + workspaceSandbox, } from '@sim/db/schema' -import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm' +import { and, asc, count, eq, exists, gt, inArray, isNull, sql } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { parseFolderPath, ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import type { ForkResourceType } from '@/ee/workspace-forking/lib/mapping/mapping-store' -import type { - ForkMcpServerMeta, - ForkRemapKind, -} from '@/ee/workspace-forking/lib/remap/remap-references' +import type { ForkMcpServerMeta, ForkRemapKind } from '@/lib/workflows/references/remap-references' export interface ForkResourceCandidate { id: string @@ -58,7 +55,12 @@ export async function getWorkspaceEnvKeys( // per kind. When `ids` is given the query is filtered to those exact ids and is NOT capped, so a // valid target sitting past the candidate cap is never wrongly dropped. Credentials, env vars // (mapping-only), and files-with-folder (copy-only) keep their own helpers below. -const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { +const tableCandidatesQuery = ( + executor: DbOrTx, + workspaceId: string, + ids?: string[], + page?: { after?: string; limit: number } +) => { const query = executor .select({ id: userTableDefinitions.id, label: userTableDefinitions.name }) .from(userTableDefinitions) @@ -66,13 +68,23 @@ const tableCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: strin and( eq(userTableDefinitions.workspaceId, workspaceId), isNull(userTableDefinitions.archivedAt), + page?.after ? gt(userTableDefinitions.id, page.after) : undefined, ids ? inArray(userTableDefinitions.id, ids) : undefined ) ) - return ids ? query : query.limit(CANDIDATE_LIMIT) + return page + ? query.orderBy(asc(userTableDefinitions.id)).limit(page.limit + 1) + : ids + ? query + : query.limit(CANDIDATE_LIMIT) } -const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { +const knowledgeBaseCandidatesQuery = ( + executor: DbOrTx, + workspaceId: string, + ids?: string[], + page?: { after?: string; limit: number } +) => { const query = executor .select({ id: knowledgeBase.id, label: knowledgeBase.name }) .from(knowledgeBase) @@ -80,28 +92,61 @@ const knowledgeBaseCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids and( eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt), + page?.after ? gt(knowledgeBase.id, page.after) : undefined, ids ? inArray(knowledgeBase.id, ids) : undefined ) ) - return ids ? query : query.limit(CANDIDATE_LIMIT) + return page + ? query.orderBy(asc(knowledgeBase.id)).limit(page.limit + 1) + : ids + ? query + : query.limit(CANDIDATE_LIMIT) } -const customToolCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { +const customToolCandidatesQuery = ( + executor: DbOrTx, + workspaceId: string, + ids?: string[], + page?: { after?: string; limit: number } +) => { const query = executor .select({ id: customTools.id, label: customTools.title }) .from(customTools) .where( - and(eq(customTools.workspaceId, workspaceId), ids ? inArray(customTools.id, ids) : undefined) + and( + eq(customTools.workspaceId, workspaceId), + page?.after ? gt(customTools.id, page.after) : undefined, + ids ? inArray(customTools.id, ids) : undefined + ) ) - return ids ? query : query.limit(CANDIDATE_LIMIT) + return page + ? query.orderBy(asc(customTools.id)).limit(page.limit + 1) + : ids + ? query + : query.limit(CANDIDATE_LIMIT) } -const skillCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { +const skillCandidatesQuery = ( + executor: DbOrTx, + workspaceId: string, + ids?: string[], + page?: { after?: string; limit: number } +) => { const query = executor .select({ id: skill.id, label: skill.name }) .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), ids ? inArray(skill.id, ids) : undefined)) - return ids ? query : query.limit(CANDIDATE_LIMIT) + .where( + and( + eq(skill.workspaceId, workspaceId), + page?.after ? gt(skill.id, page.after) : undefined, + ids ? inArray(skill.id, ids) : undefined + ) + ) + return page + ? query.orderBy(asc(skill.id)).limit(page.limit + 1) + : ids + ? query + : query.limit(CANDIDATE_LIMIT) } /** @@ -155,7 +200,12 @@ const customBlockCandidatesQuery = async ( })) } -const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: string[]) => { +const mcpServerCandidatesQuery = ( + executor: DbOrTx, + workspaceId: string, + ids?: string[], + page?: { after?: string; limit: number } +) => { const query = executor .select({ id: mcpServers.id, label: mcpServers.name }) .from(mcpServers) @@ -163,10 +213,15 @@ const mcpServerCandidatesQuery = (executor: DbOrTx, workspaceId: string, ids?: s and( eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt), + page?.after ? gt(mcpServers.id, page.after) : undefined, ids ? inArray(mcpServers.id, ids) : undefined ) ) - return ids ? query : query.limit(CANDIDATE_LIMIT) + return page + ? query.orderBy(asc(mcpServers.id)).limit(page.limit + 1) + : ids + ? query + : query.limit(CANDIDATE_LIMIT) } // Workspace-file mapping candidates are keyed by STORAGE KEY (not `workspace_files.id`): a @@ -223,9 +278,9 @@ const fileFolderCandidatesQuery = async ( const fileCandidatesWithFolderQuery = ( executor: DbOrTx, workspaceId: string, - options: { keys?: string[] } = {} + options: { keys?: string[]; page?: { after?: string; limit: number } } = {} ) => { - const { keys } = options + const { keys, page } = options const query = executor .select({ id: workspaceFiles.id, @@ -248,10 +303,15 @@ const fileCandidatesWithFolderQuery = ( eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'workspace'), isNull(workspaceFiles.deletedAt), + page?.after ? gt(workspaceFiles.id, page.after) : undefined, keys ? inArray(workspaceFiles.key, keys) : undefined ) ) - return keys ? query : query.limit(CANDIDATE_LIMIT) + return page + ? query.orderBy(asc(workspaceFiles.id)).limit(page.limit + 1) + : keys + ? query + : query.limit(CANDIDATE_LIMIT) } /** @@ -263,43 +323,68 @@ const fileCandidatesWithFolderQuery = ( * their KB is copied). `file` candidates are keyed by storage key and `file-folder` * candidates by canonical path. */ +async function sandboxCandidatesQuery(executor: DbOrTx, workspaceId: string, ids?: string[]) { + const query = executor + .select({ id: workspaceSandbox.id, label: workspaceSandbox.name }) + .from(workspaceSandbox) + .where( + and( + eq(workspaceSandbox.workspaceId, workspaceId), + ids ? inArray(workspaceSandbox.id, ids) : undefined + ) + ) + return ids ? query : query.limit(CANDIDATE_LIMIT) +} + export async function listForkResourceCandidates( executor: DbOrTx, workspaceId: string ): Promise> { - const [creds, wsEnvRows, tables, kbs, servers, tools, skills, files, fileFolders, customBlocks] = - await Promise.all([ - executor - .select({ - id: credential.id, - displayName: credential.displayName, - providerId: credential.providerId, - }) - .from(credential) - // Only real connections are mappable credentials. `env_workspace`/`env_personal` - // rows live in the same table but are environment variables (surfaced via the - // 'env-var' kind), so they must never appear as credential targets. - .where( - and( - eq(credential.workspaceId, workspaceId), - inArray(credential.type, ['oauth', 'service_account']) - ) + const [ + creds, + wsEnvRows, + tables, + kbs, + servers, + tools, + skills, + files, + fileFolders, + customBlocks, + sandboxes, + ] = await Promise.all([ + executor + .select({ + id: credential.id, + displayName: credential.displayName, + providerId: credential.providerId, + }) + .from(credential) + // Only real connections are mappable credentials. `env_workspace`/`env_personal` + // rows live in the same table but are environment variables (surfaced via the + // 'env-var' kind), so they must never appear as credential targets. + .where( + and( + eq(credential.workspaceId, workspaceId), + inArray(credential.type, ['oauth', 'service_account']) ) - .limit(CANDIDATE_LIMIT), - executor - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1), - tableCandidatesQuery(executor, workspaceId), - knowledgeBaseCandidatesQuery(executor, workspaceId), - mcpServerCandidatesQuery(executor, workspaceId), - customToolCandidatesQuery(executor, workspaceId), - skillCandidatesQuery(executor, workspaceId), - fileCandidatesQuery(executor, workspaceId), - fileFolderCandidatesQuery(executor, workspaceId), - customBlockCandidatesQuery(executor, workspaceId), - ]) + ) + .limit(CANDIDATE_LIMIT), + executor + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1), + tableCandidatesQuery(executor, workspaceId), + knowledgeBaseCandidatesQuery(executor, workspaceId), + mcpServerCandidatesQuery(executor, workspaceId), + customToolCandidatesQuery(executor, workspaceId), + skillCandidatesQuery(executor, workspaceId), + fileCandidatesQuery(executor, workspaceId), + fileFolderCandidatesQuery(executor, workspaceId), + customBlockCandidatesQuery(executor, workspaceId), + sandboxCandidatesQuery(executor, workspaceId), + ]) const envVariables = wsEnvRows[0]?.variables const envKeys = @@ -320,6 +405,7 @@ export async function listForkResourceCandidates( 'custom-tool': tools, 'custom-block': customBlocks, skill: skills, + sandbox: sandboxes, 'knowledge-document': [], file: files, 'file-folder': fileFolders, @@ -359,67 +445,82 @@ async function loadForkResourceRows( const toolIds = ids('custom-tool') const skillIds = ids('skill') const customBlockIds = ids('custom-block') + const sandboxIds = ids('sandbox') // Files are identified by storage key (not `workspace_files.id`); a copied file's mapping // target is its child storage key, so existence is checked by key in the target workspace. const fileKeys = ids('file') const fileFolderPaths = ids('file-folder') - const [creds, tables, kbs, docs, servers, tools, skills, files, fileFolders, customBlocks] = - await Promise.all([ - credIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : executor - .select({ id: credential.id, label: credential.displayName }) - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - inArray(credential.type, ['oauth', 'service_account']), - inArray(credential.id, credIds) - ) - ), - tableIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : tableCandidatesQuery(executor, workspaceId, tableIds), - kbIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds), - // Documents are validated through a KB join (they are not a standalone candidate kind), so - // this existence check stays inline rather than sharing a per-kind candidate query. - docIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : executor - .select({ id: document.id }) - .from(document) - .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) - .where( - and( - eq(knowledgeBase.workspaceId, workspaceId), - isNull(knowledgeBase.deletedAt), - isNull(document.deletedAt), - isNull(document.archivedAt), - inArray(document.id, docIds) - ) - ), - mcpIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : mcpServerCandidatesQuery(executor, workspaceId, mcpIds), - toolIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : customToolCandidatesQuery(executor, workspaceId, toolIds), - skillIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : skillCandidatesQuery(executor, workspaceId, skillIds), - fileKeys.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : fileCandidatesQuery(executor, workspaceId, fileKeys), - fileFolderPaths.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : fileFolderCandidatesQuery(executor, workspaceId, fileFolderPaths), - customBlockIds.length === 0 - ? Promise.resolve([] as ForkResourceRow[]) - : customBlockCandidatesQuery(executor, workspaceId, customBlockIds), - ]) + const [ + creds, + tables, + kbs, + docs, + servers, + tools, + skills, + files, + fileFolders, + customBlocks, + sandboxes, + ] = await Promise.all([ + credIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : executor + .select({ id: credential.id, label: credential.displayName }) + .from(credential) + .where( + and( + eq(credential.workspaceId, workspaceId), + inArray(credential.type, ['oauth', 'service_account']), + inArray(credential.id, credIds) + ) + ), + tableIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : tableCandidatesQuery(executor, workspaceId, tableIds), + kbIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : knowledgeBaseCandidatesQuery(executor, workspaceId, kbIds), + // Documents are validated through a KB join (they are not a standalone candidate kind), so + // this existence check stays inline rather than sharing a per-kind candidate query. + docIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : executor + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(knowledgeBase.workspaceId, workspaceId), + isNull(knowledgeBase.deletedAt), + isNull(document.deletedAt), + isNull(document.archivedAt), + inArray(document.id, docIds) + ) + ), + mcpIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : mcpServerCandidatesQuery(executor, workspaceId, mcpIds), + toolIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : customToolCandidatesQuery(executor, workspaceId, toolIds), + skillIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : skillCandidatesQuery(executor, workspaceId, skillIds), + fileKeys.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : fileCandidatesQuery(executor, workspaceId, fileKeys), + fileFolderPaths.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : fileFolderCandidatesQuery(executor, workspaceId, fileFolderPaths), + customBlockIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : customBlockCandidatesQuery(executor, workspaceId, customBlockIds), + sandboxIds.length === 0 + ? Promise.resolve([] as ForkResourceRow[]) + : sandboxCandidatesQuery(executor, workspaceId, sandboxIds), + ]) const result: Partial> = {} if (credIds.length > 0) result.credential = creds @@ -429,6 +530,7 @@ async function loadForkResourceRows( if (mcpIds.length > 0) result['mcp-server'] = servers if (toolIds.length > 0) result['custom-tool'] = tools if (skillIds.length > 0) result.skill = skills + if (sandboxIds.length > 0) result.sandbox = sandboxes // `fileCandidatesQuery` exposes the storage key under `id`, so file rows key by `r.id`. if (fileKeys.length > 0) result.file = files if (fileFolderPaths.length > 0) result['file-folder'] = fileFolders @@ -776,7 +878,7 @@ export async function classifyCredentialResourceType( executor: DbOrTx, credentialId: string, workspaceId: string -): Promise> { +): Promise<'oauth_credential' | 'service_account_credential'> { const [row] = await executor .select({ type: credential.type }) .from(credential) @@ -784,3 +886,43 @@ export async function classifyCredentialResourceType( .limit(1) return row?.type === 'service_account' ? 'service_account_credential' : 'oauth_credential' } + +/** Keyset pages reuse the canonical eligibility queries without the browser picker ceiling. */ +export async function listForkCopyableResourcePage( + executor: DbOrTx, + workspaceId: string, + kind: keyof Omit, + page: { after?: string; limit: number } +): Promise< + Array< + ForkResourceCandidate & { key?: string; folderId?: string | null; folderName?: string | null } + > +> { + switch (kind) { + case 'files': + return fileCandidatesWithFolderQuery(executor, workspaceId, { page }) + case 'tables': + return tableCandidatesQuery(executor, workspaceId, undefined, page) + case 'knowledgeBases': + return knowledgeBaseCandidatesQuery(executor, workspaceId, undefined, page) + case 'customTools': + return customToolCandidatesQuery(executor, workspaceId, undefined, page) + case 'skills': + return skillCandidatesQuery(executor, workspaceId, undefined, page) + case 'mcpServers': + return mcpServerCandidatesQuery(executor, workspaceId, undefined, page) + case 'workflowMcpServers': + return executor + .select({ id: workflowMcpServer.id, label: workflowMcpServer.name }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt), + page.after ? gt(workflowMcpServer.id, page.after) : undefined + ) + ) + .orderBy(asc(workflowMcpServer.id)) + .limit(page.limit + 1) + } +} diff --git a/apps/sim/lib/workflows/references/selector-values.test.ts b/apps/sim/lib/workflows/references/selector-values.test.ts new file mode 100644 index 00000000000..53562accf33 --- /dev/null +++ b/apps/sim/lib/workflows/references/selector-values.test.ts @@ -0,0 +1,79 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getOption } = vi.hoisted(() => ({ getOption: vi.fn() })) +vi.mock('@/lib/selectors/application/get-selector-option', () => ({ + getSelectorOption: { execute: getOption }, +})) + +import { + selectedReferenceValues, + workflowSelectorValidator, +} from '@/lib/workflows/references/selector-values' + +const principal = { kind: 'personal_api_key', userId: 'user', keyId: 'key' } as const + +describe('workflow selector validation', () => { + beforeEach(() => { + vi.clearAllMocks() + getOption.mockImplementation(async ({ input }) => ({ id: input.id, label: input.id })) + }) + it('validates each selected ID and deduplicates shared requests', async () => { + const validate = workflowSelectorValidator(principal, 'destination') + const field = { + selectorKey: 'table.outputColumns', + context: { tableId: 'table' }, + title: 'Columns', + value: 'first, second,first', + multiSelect: true, + } + expect(await validate(field)).toBe(true) + expect(await validate(field)).toBe(true) + expect(getOption.mock.calls.map(([args]) => args.input.id)).toEqual(['first', 'second']) + expect(getOption.mock.calls[0][0]).toMatchObject({ + principal, + input: { scope: { kind: 'workspace', workspaceId: 'destination' } }, + }) + }) + it('rejects a multi-selection with an unavailable member', async () => { + getOption.mockResolvedValueOnce({ id: 'first', label: 'First' }).mockResolvedValueOnce(null) + expect( + await workflowSelectorValidator( + principal, + 'destination' + )({ + selectorKey: 'table.outputColumns', + context: { tableId: 'table' }, + title: 'Columns', + value: 'first, missing', + multiSelect: true, + }) + ).toBe(false) + }) + it('keeps a comma inside a single selector ID', () => { + expect(selectedReferenceValues('last, first')).toEqual(['last, first']) + }) + it.each(['tool', 'server-tool', 'mcp-server-tool'])( + 'normalizes MCP selection %s to the destination tool name', + async (value) => { + expect( + await workflowSelectorValidator( + principal, + 'destination' + )({ selectorKey: 'mcp.tools', context: { mcpServerId: 'server' }, title: 'Tool', value }) + ).toBe(true) + expect(getOption).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ id: 'tool' }) }) + ) + } + ) + it('refuses unavailable dependencies before provider discovery', async () => { + await expect( + workflowSelectorValidator( + principal, + 'destination' + )({ selectorKey: 'mcp.tools', context: {}, title: 'Tool', value: 'name' }) + ).rejects.toThrow('dependencies') + expect(getOption).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/references/selector-values.ts b/apps/sim/lib/workflows/references/selector-values.ts new file mode 100644 index 00000000000..e4d152101d1 --- /dev/null +++ b/apps/sim/lib/workflows/references/selector-values.ts @@ -0,0 +1,71 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getSelectorOption } from '@/lib/selectors/application/get-selector-option' +import { + getSelectorManifestEntry, + isSelectorReady, + type SelectorKey, + type ServerSelectorKey, +} from '@/lib/selectors/manifest' +import type { SafeSelectorOption, SelectorContext } from '@/lib/selectors/types' + +/** Matches the editor's comma-separated selector representation without splitting scalar IDs. */ +export function selectedReferenceValues(value: string, multiple = false): string[] { + return [ + ...new Set((multiple ? value.split(',') : [value]).map((item) => item.trim()).filter(Boolean)), + ] +} + +/** Request-local, bounded option verification shared by import and workspace sync. */ +export function workflowSelectorValidator(principal: Principal, workspaceId: string) { + const requests = new Map>() + return async (field: { + selectorKey: string + context: SelectorContext + value: string + title: string + multiSelect?: boolean + }) => { + const key = field.selectorKey as SelectorKey + const manifest = getSelectorManifestEntry(key) + if (manifest.classification === 'local') return true + if (!isSelectorReady(key, field.context)) + throw new OrchestrationError( + 'validation', + `Configure the destination dependencies of ${field.title} first` + ) + let values = selectedReferenceValues(field.value, field.multiSelect) + if (key === 'mcp.tools' && field.context.mcpServerId) { + const serverId = field.context.mcpServerId + values = values.map((value) => { + for (const prefix of [`mcp-${serverId}-`, `${serverId}-`]) + if (value.startsWith(prefix)) return value.slice(prefix.length) + return value + }) + } + for (const id of values) { + const cacheKey = JSON.stringify([key, field.context, id]) + if (!requests.has(cacheKey)) { + if (requests.size >= 100) + throw new OrchestrationError( + 'payload_too_large', + 'Operation exceeds 100 distinct selector validations' + ) + requests.set( + cacheKey, + getSelectorOption.execute({ + principal, + input: { + selectorKey: key as ServerSelectorKey, + scope: { kind: 'workspace', workspaceId }, + context: field.context, + id, + }, + }) + ) + } + if ((await requests.get(cacheKey))?.id !== id) return false + } + return true + } +} diff --git a/apps/sim/lib/workflows/references/types.ts b/apps/sim/lib/workflows/references/types.ts new file mode 100644 index 00000000000..3a954aab6c7 --- /dev/null +++ b/apps/sim/lib/workflows/references/types.ts @@ -0,0 +1,41 @@ +/** Registered workspace references shared by workflow imports and fork synchronization. */ +export const WORKFLOW_RESOURCE_KINDS = [ + 'credential', + 'env-var', + 'knowledge-base', + 'knowledge-document', + 'table', + 'file', + 'file-folder', + 'mcp-server', + 'custom-tool', + 'custom-block', + 'skill', + 'sandbox', +] as const + +export type WorkflowResourceKind = (typeof WORKFLOW_RESOURCE_KINDS)[number] +export type PortableResourceKind = WorkflowResourceKind | 'workflow' + +export interface ReferenceOccurrence { + blockId: string + subBlockKey: string + valuePath: Array + positions?: number[] + encoding: 'scalar' | 'array' | 'csv' | 'files' | 'environment' +} + +export interface PortableReference { + kind: PortableResourceKind + sourceId: string + required: boolean + occurrences: ReferenceOccurrence[] +} + +export interface WorkflowReferenceManifest { + version: 1 + references: PortableReference[] +} + +/** Resolves a block identity in the destination graph; imports may use an identity resolver. */ +export type WorkflowBlockIdResolver = (targetWorkflowId: string, sourceBlockId: string) => string diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 01a7c0b4a8b..5878ea23889 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -682,7 +682,10 @@ export function sanitizeForCopilot( * Sanitize workflow state for export by removing secrets but keeping positions * Users need positions to restore the visual layout when importing */ -export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { +export function sanitizeForExport( + state: WorkflowState, + options: { includeReferences?: boolean } = {} +): ExportWorkflowState { const canonicalLoops = generateLoopBlocks(state.blocks || {}) const canonicalParallels = generateParallelBlocks(state.blocks || {}) @@ -700,6 +703,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { const sanitizedState = sanitizeWorkflowForSharing(fullState, { preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows redactOpaqueCredentialInputs: true, + preserveReferenceMetadata: options.includeReferences, }) as ExportWorkflowState['state'] return { diff --git a/apps/sim/lib/workflows/search-replace/resources/registry.ts b/apps/sim/lib/workflows/search-replace/resources/registry.ts index bf61b54838f..bbd4f33f4e7 100644 --- a/apps/sim/lib/workflows/search-replace/resources/registry.ts +++ b/apps/sim/lib/workflows/search-replace/resources/registry.ts @@ -39,6 +39,7 @@ interface ResourceCodecReplaceResult { } interface WorkflowSearchResourceCodec { + remap?(value: unknown, resolve: (sourceId: string) => string): unknown parse(params: ResourceCodecParseParams): StructuredResourceReference[] contains(value: unknown, rawValue: string): boolean replace( @@ -196,6 +197,24 @@ function parseFileReplacement(replacement: string): ResourceCodecReplaceResult { } const scalarResourceCodec: WorkflowSearchResourceCodec = { + remap(value, resolve) { + const map = (item: unknown): unknown => { + if (Array.isArray(item)) { + const next = item.map(map) + return next.some((value, index) => value !== item[index]) ? next : item + } + if (typeof item !== 'string') return item + return item + .split(',') + .map((part) => { + const id = part.trim() + const target = id ? resolve(id) : id + return target === id ? part : target + }) + .join(',') + } + return map(value) + }, parse({ value, kind, subBlockConfig, selectorContext }) { const values = splitCommaResourceValue(value) return values.map((rawValue, index) => ({ diff --git a/apps/sim/lib/workflows/variables/parse.ts b/apps/sim/lib/workflows/variables/parse.ts index 58c5a41cfa2..3d5416b14b5 100644 --- a/apps/sim/lib/workflows/variables/parse.ts +++ b/apps/sim/lib/workflows/variables/parse.ts @@ -82,7 +82,10 @@ export function parseWorkflowVariables( * unrecognized `type` falls back to `'string'` rather than being written * through to the JSONB column verbatim. */ -export function normalizeImportedVariables(variables: unknown): Record { +export function normalizeImportedVariables( + variables: unknown, + missingId: (index: number) => string = generateId +): Record { /** * Assembled on a null-prototype object so a `__proto__` key lands as an * ordinary own property instead of invoking the prototype setter, then @@ -96,11 +99,11 @@ export function normalizeImportedVariables(variables: unknown): Record [undefined, value]) : Object.entries(variables) - for (const [key, value] of entries) { + for (const [index, [key, value]] of entries.entries()) { if (!value || typeof value !== 'object') continue const raw = value as Partial const rawId = typeof raw.id === 'string' ? raw.id.trim() : '' - const id = rawId || key || generateId() + const id = rawId || key || missingId(index) record[id] = { id, diff --git a/apps/sim/lib/workspaces/__integration__/README.md b/apps/sim/lib/workspaces/__integration__/README.md new file mode 100644 index 00000000000..f65e6cc72c2 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/README.md @@ -0,0 +1,17 @@ +# Workflow import and workspace sync harness + +Run from the repository root: + +```sh +bun run test:workflow-sync +``` + +Requires Bun, installed workspace dependencies, and a running Docker daemon. The runner starts PostgreSQL 17 with pgvector on a random loopback port, applies the current schema, runs the tests, and removes the container. It never reads the application's database connection or mounts a database volume. Integration setup rejects nonlocal and nonfixture database names and enables the transaction tripwire. + +The tests exercise real PostgreSQL transactions, locks, application authorization, v2 route adapters, API-key authentication, CLI subprocesses, and deployment outbox workers. HTTP callbacks to the separate realtime process use an authenticated local fixture. Provider discovery and failure cases have focused Vitest tests with controlled provider responses. + +Coverage includes concurrent identical requests, reused request IDs with changed payloads, failure before commit, retries after lost responses, revoked access, mapping refusal, sanitized imports, regenerated block/edge/variable identities, inline tools, draft-only forks, push and pull from either side, inherited locks, stale previews, exclusions, deleted versus undeployed workflows, immutable deployment snapshots, deployment readiness, and pagination across microsecond timestamps. Copy lifecycle tests cover interrupted workers, checkpoint loss, changed source documents, partial failure and receipt recovery. + +To add an integration scenario, place `*.integration.ts` beside this file. Create a fixture user and workspace, use generated IDs, and clean up that workspace and user in `afterAll`. Do not import the normal application test setup: its database mocks would defeat the concurrency checks. Keep external services on disposable local fixtures. + +The harness tests against the expanded schema. Validate migration safety separately with `bun run check:migrations`; do not point this runner at an existing database to test deployment migrations. diff --git a/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts new file mode 100644 index 00000000000..7768e5148e2 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/fork-sync.integration.ts @@ -0,0 +1,666 @@ +import { db } from '@sim/db' +import { + folder, + outboxEvent, + permissions, + user, + workflow, + workflowBlocks, + workflowDeploymentOperation, + workflowDeploymentVersion, + workspace, + workspaceForkResourceMap, + workspaceOperationReceipt, + workspaceSandbox, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { processOutboxEventById } from '@/lib/core/outbox/service' +import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' +import { admitWorkflowState, saveAdmittedWorkflowState } from '@/lib/workflows/persistence/utils' +import { getWorkspaceOperation } from '@/lib/workspaces/operations/application' +import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { + forkWorkspace, + previewWorkspaceFork, + previewWorkspaceSync, + syncWorkspace, +} from '@/ee/workspace-forking/application/create-and-sync' +import { assertForkSourceVersions } from '@/ee/workspace-forking/application/revision' +import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const userId = generateId() +const sourceWorkspaceId = generateId() +const sourceWorkflowId = generateId() +const principal = { kind: 'personal_api_key' as const, userId, keyId: generateId() } +const createdWorkspaceIds: string[] = [] +const graph: WorkflowState = { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + enabled: true, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + }, + compute: { + id: 'compute', + type: 'function', + name: 'Compute', + enabled: true, + position: { x: 200, y: 0 }, + subBlocks: { + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + code: { id: 'code', type: 'code', value: 'return 42' }, + }, + outputs: {}, + }, + }, + edges: [ + { + id: 'edge', + source: 'start', + target: 'compute', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + loops: {}, + parallels: {}, + variables: {}, +} + +async function finishDeployments(report: WorkspaceOperationReport) { + const registry = { ...workflowDeploymentOutboxHandlers, ...workspaceOperationOutboxHandlers } + for (const id of report.effectEventIds ?? []) { + const outcome = await processOutboxEventById(id, registry) + const [storedEvent] = await db + .select({ lastError: outboxEvent.lastError, status: outboxEvent.status }) + .from(outboxEvent) + .where(eq(outboxEvent.id, id)) + expect(outcome, `Outbox effect ${id}: ${JSON.stringify(storedEvent)}`).toBe('completed') + } + return getWorkspaceOperation.execute({ + principal, + input: { workspaceId: report.workspaceId, operationId: report.operationId }, + }) +} + +async function createChild() { + const input = { workspaceId: sourceWorkspaceId, name: `Edge ${generateId()}` } + const preview = await previewWorkspaceFork.execute({ principal, input }) + const result = await forkWorkspace.execute({ + principal, + input: { ...input, requestId: generateId(), previewFingerprint: preview.previewFingerprint }, + }) + createdWorkspaceIds.push(result.workspace.id) + return result.workspace.id +} + +describe('authorized fork and sync against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Fork fixture', + email: `${userId}@workflow.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: sourceWorkspaceId, + name: 'Fork source fixture', + ownerId: userId, + billedAccountUserId: userId, + allowPersonalApiKeys: true, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: sourceWorkspaceId, + permissionType: 'admin', + }) + await db.insert(workflow).values({ + id: sourceWorkflowId, + userId, + workspaceId: sourceWorkspaceId, + name: 'Deploy fixture', + isDeployed: true, + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + const admitted = await admitWorkflowState(graph, { + subjectUserId: userId, + workspaceId: sourceWorkspaceId, + }) + await db.transaction((tx) => saveAdmittedWorkflowState(tx, sourceWorkflowId, admitted)) + await db.insert(workflowDeploymentVersion).values({ + id: generateId(), + workflowId: sourceWorkflowId, + version: 1, + state: graph, + isActive: true, + createdBy: userId, + }) + }) + afterAll(async () => { + for (const id of createdWorkspaceIds) await db.delete(workspace).where(eq(workspace.id, id)) + await db.delete(workspace).where(eq(workspace.id, sourceWorkspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + it('forks once under concurrent requests and creates only drafts', async () => { + const input = { workspaceId: sourceWorkspaceId, name: 'Fork destination fixture' } + const preview = await previewWorkspaceFork.execute({ principal, input }) + expect(preview.workflows).toHaveLength(1) + const apply = { + ...input, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + } + const results = await Promise.all( + Array.from({ length: 5 }, () => forkWorkspace.execute({ principal, input: apply })) + ) + const childId = results[0].workspace.id + createdWorkspaceIds.push(childId) + expect(new Set(results.map((result) => result.workspace.id)).size).toBe(1) + const rows = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + expect(rows).toHaveLength(1) + expect(rows[0].isDeployed).toBe(false) + expect( + await db + .select() + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, rows[0].id)) + ).toHaveLength(0) + expect((await forkWorkspace.execute({ principal, input: apply })).operation?.operationId).toBe( + results[0].operation?.operationId + ) + await expect( + forkWorkspace.execute({ principal, input: { ...apply, name: 'Different fork' } }) + ).rejects.toThrow('different inputs') + }) + + it('admits one immutable deployment when the parent pushes to its child', async () => { + const childId = createdWorkspaceIds[0] + expect(childId).toBeTruthy() + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + const preview = await previewWorkspaceSync.execute({ principal, input }) + expect(preview.ready).toBe(true) + const apply = { + ...input, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + } + const results = await Promise.all( + Array.from({ length: 5 }, () => syncWorkspace.execute({ principal, input: apply })) + ) + expect(new Set(results.map((result) => result.operation?.operationId)).size).toBe(1) + const report = results[0].operation! + expect(report.applied).toBe(true) + expect(report.deploymentOperationIds).toHaveLength(1) + const [attempt] = await db + .select() + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.id, report.deploymentOperationIds![0])) + const [version] = await db + .select() + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.id, attempt.deploymentVersionId)) + await db + .update(workflowBlocks) + .set({ name: 'A later draft edit' }) + .where( + and(eq(workflowBlocks.workflowId, attempt.workflowId), eq(workflowBlocks.type, 'function')) + ) + const [unchanged] = await db + .select() + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.id, attempt.deploymentVersionId)) + expect(unchanged.state).toEqual(version.state) + const completed = await finishDeployments(report) + expect(completed.status).toBe('completed') + expect(completed.deployments).toEqual([ + expect.objectContaining({ ready: true, operationId: attempt.id }), + ]) + const [active] = await db + .select() + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.id, attempt.deploymentVersionId)) + expect(active.isActive).toBe(true) + expect(active.state).toEqual(version.state) + + const [identity] = await db + .select() + .from(workspaceForkResourceMap) + .where( + and( + eq(workspaceForkResourceMap.childWorkspaceId, childId), + eq(workspaceForkResourceMap.resourceType, 'workflow') + ) + ) + expect(identity.parentResourceId).toBe(sourceWorkflowId) + expect(identity.childResourceId).toBe(attempt.workflowId) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, apply.requestId)) + ).toHaveLength(1) + expect((await syncWorkspace.execute({ principal, input: apply })).operation?.operationId).toBe( + report.operationId + ) + expect( + await db + .select({ id: workflowDeploymentOperation.id }) + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, attempt.workflowId)) + ).toEqual([{ id: attempt.id }]) + expect( + await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, attempt.workflowId)) + ).toEqual([{ id: attempt.deploymentVersionId }]) + }) + it.each([ + { acting: 'child', direction: 'pull' as const }, + { acting: 'child', direction: 'push' as const }, + { acting: 'parent', direction: 'pull' as const }, + ])( + 'uses canonical source/target orientation for $acting $direction', + async ({ acting, direction }) => { + const childId = await createChild() + const [childWorkflow] = await db + .select() + .from(workflow) + .where(eq(workflow.workspaceId, childId)) + const seed = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + const seedPreview = await previewWorkspaceSync.execute({ principal, input: seed }) + const seeded = await syncWorkspace.execute({ + principal, + input: { + ...seed, + requestId: generateId(), + previewFingerprint: seedPreview.previewFingerprint, + }, + }) + await finishDeployments(seeded.operation!) + const input = { + workspaceId: acting === 'child' ? childId : sourceWorkspaceId, + otherWorkspaceId: acting === 'child' ? sourceWorkspaceId : childId, + direction, + } + const preview = await previewWorkspaceSync.execute({ principal, input }) + const result = await syncWorkspace.execute({ + principal, + input: { + ...input, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + }, + }) + const targetId = + (acting === 'child') === (direction === 'push') ? sourceWorkflowId : childWorkflow.id + expect(result.operation?.resourceIds).toContain(targetId) + expect((await finishDeployments(result.operation!)).status).toBe('completed') + const [mapping] = await db + .select() + .from(workspaceForkResourceMap) + .where( + and( + eq(workspaceForkResourceMap.childWorkspaceId, childId), + eq(workspaceForkResourceMap.resourceType, 'workflow') + ) + ) + expect(mapping.parentResourceId).toBe(sourceWorkflowId) + expect(mapping.childResourceId).toBe(childWorkflow.id) + } + ) + + it('refuses a changed target graph before committing a receipt or deployment', async () => { + const childId = await createChild() + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + const preview = await previewWorkspaceSync.execute({ principal, input }) + const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + await db + .update(workflowBlocks) + .set({ name: 'Concurrent edit' }) + .where(eq(workflowBlocks.workflowId, target.id)) + const requestId = generateId() + await expect( + syncWorkspace.execute({ + principal, + input: { ...input, requestId, previewFingerprint: preview.previewFingerprint }, + }) + ).rejects.toMatchObject({ + details: expect.objectContaining({ applied: false, reason: 'stale_preview' }), + }) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(0) + expect( + await db + .select() + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, target.id)) + ).toHaveLength(0) + }) + + it('commits inline sandbox mappings with sync only after a fresh preview in parent-to-child orientation', async () => { + const childId = await createChild() + const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + const [sourceVersion] = await db + .select() + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, sourceWorkflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + const sourceSandboxId = generateId() + const childSandboxId = generateId() + await db.insert(workspaceSandbox).values([ + { + id: sourceSandboxId, + workspaceId: sourceWorkspaceId, + name: `Source sandbox ${sourceSandboxId}`, + language: 'javascript', + specHash: 'fork-source-fixture', + createdBy: userId, + }, + { + id: childSandboxId, + workspaceId: childId, + name: 'Child sandbox', + language: 'javascript', + specHash: 'fork-child-fixture', + createdBy: userId, + }, + ]) + const sourceState = structuredClone(sourceVersion.state) as WorkflowState + const sourceFunction = Object.values(sourceState.blocks).find( + (block) => block.type === 'function' + ) + if (!sourceFunction) throw new Error('The source fixture requires a Function block') + sourceFunction.subBlocks.sandboxId = { + id: 'sandboxId', + type: 'combobox', + value: sourceSandboxId, + } + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + mappings: [ + { resourceType: 'sandbox' as const, sourceId: sourceSandboxId, targetId: childSandboxId }, + ], + } + const readSandboxMappings = () => + db + .select() + .from(workspaceForkResourceMap) + .where( + and( + eq(workspaceForkResourceMap.childWorkspaceId, childId), + eq(workspaceForkResourceMap.resourceType, 'sandbox') + ) + ) + try { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceState }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + const preview = await previewWorkspaceSync.execute({ principal, input }) + expect(preview.ready).toBe(true) + expect(await readSandboxMappings()).toHaveLength(0) + + await db + .update(workflowBlocks) + .set({ name: 'Concurrent sandbox draft edit' }) + .where(and(eq(workflowBlocks.workflowId, target.id), eq(workflowBlocks.type, 'function'))) + const requestId = generateId() + await expect( + syncWorkspace.execute({ + principal, + input: { ...input, requestId, previewFingerprint: preview.previewFingerprint }, + }) + ).rejects.toMatchObject({ + details: expect.objectContaining({ applied: false, reason: 'stale_preview' }), + }) + expect(await readSandboxMappings()).toHaveLength(0) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(0) + const [unchangedTarget] = await db + .select() + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, target.id), eq(workflowBlocks.type, 'function'))) + expect(unchangedTarget.name).toBe('Concurrent sandbox draft edit') + expect(unchangedTarget.subBlocks).not.toHaveProperty('sandboxId.value', childSandboxId) + + const fresh = await previewWorkspaceSync.execute({ principal, input }) + expect(fresh.ready).toBe(true) + expect(fresh.previewFingerprint).not.toBe(preview.previewFingerprint) + expect(await readSandboxMappings()).toHaveLength(0) + const result = await syncWorkspace.execute({ + principal, + input: { ...input, requestId, previewFingerprint: fresh.previewFingerprint }, + }) + expect(result.operation?.applied).toBe(true) + expect(await readSandboxMappings()).toEqual([ + expect.objectContaining({ + childWorkspaceId: childId, + parentResourceId: sourceSandboxId, + childResourceId: childSandboxId, + }), + ]) + const [mappedTarget] = await db + .select() + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, target.id), eq(workflowBlocks.type, 'function'))) + expect(mappedTarget.subBlocks).toMatchObject({ sandboxId: { value: childSandboxId } }) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toEqual([expect.objectContaining({ id: result.operation!.operationId })]) + expect((await finishDeployments(result.operation!)).status).toBe('completed') + const [deployedTarget] = await db + .select() + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, target.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + expect( + Object.values((deployedTarget.state as WorkflowState).blocks).find( + (block) => block.type === 'function' + )?.subBlocks + ).toMatchObject({ sandboxId: { value: childSandboxId } }) + } finally { + await db + .update(workflowDeploymentVersion) + .set({ state: sourceVersion.state }) + .where(eq(workflowDeploymentVersion.id, sourceVersion.id)) + } + }) + + it('refuses inherited folder locks with no committed mutation', async () => { + const childId = await createChild() + const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + workspaceId: childId, + userId, + name: 'Locked', + resourceType: 'workflow', + locked: true, + }) + await db.update(workflow).set({ folderId }).where(eq(workflow.id, target.id)) + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + const preview = await previewWorkspaceSync.execute({ principal, input }) + const requestId = generateId() + await expect( + syncWorkspace.execute({ + principal, + input: { ...input, requestId, previewFingerprint: preview.previewFingerprint }, + }) + ).rejects.toThrow('locked') + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(0) + }) + + it('checks source snapshot content as well as deployment identity under apply locks', async () => { + const loaded = await loadSourceDeployedStates(sourceWorkspaceId) + const expected = loaded.sourceVersionIds.get(sourceWorkflowId)! + await db.transaction(async (tx) => { + const [version] = await tx + .select() + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.id, expected.id)) + const changed = structuredClone(version.state) as WorkflowState + Object.values(changed.blocks)[0].name = 'Changed immutable source' + await tx + .update(workflowDeploymentVersion) + .set({ state: changed }) + .where(eq(workflowDeploymentVersion.id, expected.id)) + await expect( + assertForkSourceVersions(tx, sourceWorkspaceId, loaded.sourceVersionIds) + ).rejects.toThrow('Source deployment changed') + await tx + .update(workflowDeploymentVersion) + .set({ state: version.state }) + .where(eq(workflowDeploymentVersion.id, expected.id)) + await assertForkSourceVersions(tx, sourceWorkspaceId, loaded.sourceVersionIds) + }) + }) + + it('leaves undeployed source targets alone and archives mapped targets only after source deletion', async () => { + const childId = await createChild() + const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + try { + await db.update(workflow).set({ isDeployed: false }).where(eq(workflow.id, sourceWorkflowId)) + let preview = await previewWorkspaceSync.execute({ principal, input }) + let result = await syncWorkspace.execute({ + principal, + input: { + ...input, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + }, + }) + expect(result.archived).toBe(0) + let [stored] = await db.select().from(workflow).where(eq(workflow.id, target.id)) + expect(stored.archivedAt).toBeNull() + await db + .update(workflow) + .set({ archivedAt: new Date() }) + .where(eq(workflow.id, sourceWorkflowId)) + preview = await previewWorkspaceSync.execute({ principal, input }) + result = await syncWorkspace.execute({ + principal, + input: { + ...input, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + }, + }) + expect(result.archived).toBe(1) + ;[stored] = await db.select().from(workflow).where(eq(workflow.id, target.id)) + expect(stored.archivedAt).not.toBeNull() + } finally { + await db + .update(workflow) + .set({ isDeployed: true, archivedAt: null }) + .where(eq(workflow.id, sourceWorkflowId)) + } + }) + + it('preserves exclusions and refuses stale exclusion choices atomically', async () => { + const childId = await createChild() + const [target] = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + const input = { + workspaceId: sourceWorkspaceId, + otherWorkspaceId: childId, + direction: 'push' as const, + } + const oldPreview = await previewWorkspaceSync.execute({ principal, input }) + await db.update(workflow).set({ forkSyncExcluded: true }).where(eq(workflow.id, target.id)) + const refusedId = generateId() + await expect( + syncWorkspace.execute({ + principal, + input: { + ...input, + requestId: refusedId, + previewFingerprint: oldPreview.previewFingerprint, + }, + }) + ).rejects.toThrow('stale') + const preview = await previewWorkspaceSync.execute({ principal, input }) + expect(preview.excludedTargets).toContainEqual(expect.objectContaining({ id: target.id })) + const applied = await syncWorkspace.execute({ + principal, + input: { ...input, requestId: generateId(), previewFingerprint: preview.previewFingerprint }, + }) + expect(applied.operation!.resourceIds).not.toContain(target.id) + expect( + await db + .select() + .from(workflowDeploymentOperation) + .where(eq(workflowDeploymentOperation.workflowId, target.id)) + ).toHaveLength(0) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, refusedId)) + ).toHaveLength(0) + }) +}) diff --git a/apps/sim/lib/workspaces/__integration__/http-cli.integration.ts b/apps/sim/lib/workspaces/__integration__/http-cli.integration.ts new file mode 100644 index 00000000000..90d4df66033 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/http-cli.integration.ts @@ -0,0 +1,335 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { db } from '@sim/db' +import { + apiKey, + permissions, + user, + workflow, + workspace, + workspaceOperationReceipt, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { hashApiKey } from '@/lib/api-key/crypto' +import { POST as importPreview } from '@/app/api/v2/workflows/import/preview/route' +import { POST as importApply } from '@/app/api/v2/workflows/import/route' +import { POST as forkPreview } from '@/app/api/v2/workspaces/[workspaceId]/fork/preview/route' +import { POST as pushApply } from '@/app/api/v2/workspaces/[workspaceId]/fork/push/route' +import { POST as forkApply } from '@/app/api/v2/workspaces/[workspaceId]/fork/route' +import { GET as operationGet } from '@/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route' +import { GET as operationsList } from '@/app/api/v2/workspaces/[workspaceId]/operations/route' + +const userId = generateId() +const workspaceId = generateId() +const personalKey = `sk-sim-fixture-${generateId()}` +const workspaceKey = `sk-sim-fixture-${generateId()}` +const childWorkspaceIds: string[] = [] +let endpoint: string +let directory: string +let server: Server +let requestCount = 0 +let corruptNextMutationResponse = false +const cliPath = resolve(process.cwd(), '../../packages/sim-cli/src/index.ts') +const source = { + blocks: { + start: { + id: 'start', + type: 'start_trigger', + name: 'Start', + enabled: true, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} + +async function cli(args: string[], stdin?: string, key = personalKey) { + return new Promise<{ code: number; stdout: string; stderr: string }>((resolveResult, reject) => { + const child = spawn( + 'bun', + [ + '--no-env-file', + cliPath, + '--endpoint', + endpoint, + '--workspace', + workspaceId, + '--output', + 'json', + ...args, + ], + { + cwd: directory, + env: { ...process.env, SIM_CONFIG_DIR: directory, SIM_API_KEY: key, NO_COLOR: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.on('error', reject) + child.on('close', (code) => resolveResult({ code: code ?? 1, stdout, stderr })) + child.stdin.end(stdin) + }) +} + +/** Exercises the real adapters/authentication over loopback HTTP and the installed CLI entrypoint. */ +describe('v2 and CLI workflow protocol against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + directory = await mkdtemp(resolve(tmpdir(), 'sim-workflow-cli-')) + await writeFile(resolve(directory, 'workflow.json'), JSON.stringify(source)) + await writeFile(resolve(directory, 'mappings.json'), '[]') + await db.insert(user).values({ + id: userId, + name: 'HTTP fixture', + email: `${userId}@workflow.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'HTTP fixture', + ownerId: userId, + billedAccountUserId: userId, + allowPersonalApiKeys: true, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + await db.insert(apiKey).values([ + { + id: generateId(), + userId, + name: 'Personal fixture', + key: personalKey, + keyHash: hashApiKey(personalKey), + type: 'personal', + }, + { + id: generateId(), + userId, + workspaceId, + name: 'Workspace fixture', + key: workspaceKey, + keyHash: hashApiKey(workspaceKey), + type: 'workspace', + }, + ]) + server = createServer(async (incoming, outgoing) => { + try { + requestCount++ + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of incoming) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > 11 * 1024 * 1024) throw new Error('Fixture body limit exceeded') + chunks.push(buffer) + } + const headers = new Headers() + headers.set('x-forwarded-for', '127.0.0.1') + for (const [name, value] of Object.entries(incoming.headers)) + if (value) headers.set(name, Array.isArray(value) ? value.join(', ') : value) + const request = new NextRequest(`${endpoint}${incoming.url}`, { + method: incoming.method, + headers, + ...(chunks.length ? { body: Buffer.concat(chunks).toString('utf8') } : {}), + }) + const path = new URL(request.url).pathname + const match = path.match(/^\/api\/v2\/workspaces\/([^/]+)\/(.*)$/) + const context = { params: Promise.resolve({ workspaceId: match?.[1] ?? workspaceId }) } + const response = + path === '/api/v2/workflows/import/preview' + ? await importPreview(request, { params: Promise.resolve({}) }) + : path === '/api/v2/workflows/import' + ? await importApply(request, { params: Promise.resolve({}) }) + : match?.[2] === 'fork/preview' + ? await forkPreview(request, context) + : match?.[2] === 'fork' + ? await forkApply(request, context) + : match?.[2] === 'fork/push' + ? await pushApply(request, context) + : match?.[2] === 'operations' + ? await operationsList(request, context) + : match?.[2].startsWith('operations/') + ? await operationGet(request, { + params: Promise.resolve({ + workspaceId: match[1], + operationId: match[2].slice('operations/'.length), + }), + }) + : new Response('Unknown fixture route', { status: 404 }) + outgoing.statusCode = response.status + response.headers.forEach((value, name) => outgoing.setHeader(name, value)) + const body = await response.text() + if (corruptNextMutationResponse && path === '/api/v2/workflows/import' && response.ok) { + corruptNextMutationResponse = false + outgoing.end('{') + } else outgoing.end(body) + } catch (error) { + outgoing.statusCode = 500 + outgoing.end(JSON.stringify({ fixtureError: String(error) })) + } + }) + await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Fixture did not bind loopback') + endpoint = `http://127.0.0.1:${address.port}` + }) + + afterAll(async () => { + if (server) + await new Promise((resolveClose, reject) => { + server.close((error) => (error ? reject(error) : resolveClose())) + server.closeAllConnections() + }) + for (const id of childWorkspaceIds) await db.delete(workspace).where(eq(workspace.id, id)) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await rm(directory, { recursive: true, force: true }) + await db.$client.end() + }) + + it('previews stdin JSON, applies @file input, waits, and returns the same receipt on retry', async () => { + const preview = await cli( + ['workflows', 'import-preview', '--workflow', '@-', '--mappings', '@mappings.json'], + JSON.stringify(source) + ) + expect(preview.code, preview.stderr).toBe(0) + const fingerprint = JSON.parse(preview.stdout).previewFingerprint as string + expect(fingerprint).toMatch(/^[a-f0-9]{64}$/) + const requestId = generateId() + const args = [ + 'workflows', + 'import', + '--workflow', + '@workflow.json', + '--mappings', + '@mappings.json', + '--preview-fingerprint', + fingerprint, + '--request-id', + requestId, + '--wait', + ] + const applied = await cli(args) + expect(applied.code, applied.stderr).toBe(0) + const report = JSON.parse(applied.stdout) + expect(report).toMatchObject({ requestId, applied: true, status: 'completed' }) + const retry = await cli(args) + expect(retry.code, retry.stderr).toBe(0) + expect(JSON.parse(retry.stdout).operationId).toBe(report.operationId) + const wait = await cli(['workspaces', 'operations', 'wait', report.operationId]) + expect(wait.code, wait.stderr).toBe(0) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(1) + }) + + it('retains the request ID after a committed response is corrupted and reconciles by retry', async () => { + const preview = await cli([ + 'workflows', + 'import-preview', + '--workflow', + '@workflow.json', + '--mappings', + '@mappings.json', + ]) + expect(preview.code, preview.stderr).toBe(0) + const requestId = generateId() + const args = [ + 'workflows', + 'import', + '--workflow', + '@workflow.json', + '--mappings', + '@mappings.json', + '--preview-fingerprint', + JSON.parse(preview.stdout).previewFingerprint, + '--request-id', + requestId, + ] + corruptNextMutationResponse = true + const uncertain = await cli(args) + expect(uncertain.code).toBe(1) + expect(uncertain.stderr).toContain(requestId) + expect(uncertain.stderr).toContain('MUTATION_OUTCOME_UNKNOWN') + const retry = await cli(args) + expect(retry.code, retry.stderr).toBe(0) + expect(JSON.parse(retry.stdout)).toMatchObject({ requestId, applied: true }) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(1) + }) + + it('requires a personal principal for fork administration and refuses unconfirmed sync before HTTP', async () => { + const refused = await cli(['workspaces', 'fork-preview'], undefined, workspaceKey) + expect(refused.code).toBe(1) + expect(refused.stderr).toContain('WORKSPACE_KEY_OPERATION_NOT_PERMITTED') + const before = requestCount + const unconfirmed = await cli([ + 'workspaces', + 'push', + '--other-workspace-id', + generateId(), + '--preview-fingerprint', + 'a'.repeat(64), + '--request-id', + generateId(), + ]) + expect(unconfirmed.code).toBe(1) + expect(unconfirmed.stderr).toContain('--yes') + expect(requestCount).toBe(before) + }) + + it('creates a draft fork through the CLI and follows its operation receipt', async () => { + const preview = await cli(['workspaces', 'fork-preview', '--name', 'CLI fork fixture']) + expect(preview.code, preview.stderr).toBe(0) + const created = await cli([ + 'workspaces', + 'fork', + '--name', + 'CLI fork fixture', + '--preview-fingerprint', + JSON.parse(preview.stdout).previewFingerprint, + '--request-id', + generateId(), + '--wait', + ]) + expect(created.code, created.stderr).toBe(0) + const report = JSON.parse(created.stdout) + const childId = report.resourceIds[0] as string + childWorkspaceIds.push(childId) + const drafts = await db.select().from(workflow).where(eq(workflow.workspaceId, childId)) + expect(drafts.length).toBeGreaterThan(0) + expect(drafts.every((draft) => !draft.isDeployed)).toBe(true) + }) +}) diff --git a/apps/sim/lib/workspaces/__integration__/mapped-import.integration.ts b/apps/sim/lib/workspaces/__integration__/mapped-import.integration.ts new file mode 100644 index 00000000000..9220d715c30 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/mapped-import.integration.ts @@ -0,0 +1,262 @@ +import { db } from '@sim/db' +import { + customTools, + outboxEvent, + permissions, + user, + workflow, + workflowBlocks, + workflowEdges, + workspace, + workspaceOperationReceipt, + workspaceSandbox, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { importWorkflow } from '@/lib/workflows/application/import-export' +import { previewWorkflowImport } from '@/lib/workflows/application/mapped-import' +import { buildWorkflowReferenceManifest } from '@/lib/workflows/references/manifest' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const userId = generateId() +const workspaceId = generateId() +const sandboxId = generateId() +const permissionId = generateId() +const principal = { kind: 'personal_api_key', userId, keyId: generateId() } as const + +function input(name: string) { + const state: WorkflowState = { + blocks: { + fn: { + id: 'fn', + type: 'function', + name: 'Compute', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + language: { id: 'language', type: 'dropdown', value: 'javascript' }, + code: { id: 'code', type: 'code', value: 'return 42' }, + sandboxId: { id: 'sandboxId', type: 'combobox', value: 'source-sandbox-label' }, + }, + }, + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 200, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: JSON.stringify([ + { + type: 'custom-tool', + title: name, + code: 'return 42', + schema: { + type: 'function', + function: { name: 'answer', parameters: { type: 'object', properties: {} } }, + }, + }, + ]), + }, + }, + }, + }, + edges: [{ id: 'source-edge', source: 'fn', target: 'agent' }], + loops: {}, + parallels: {}, + variables: { answer: { id: 'answer', name: 'answer', type: 'number', value: 42 } }, + } + return { + workspaceId, + name, + workflow: { + ...sanitizeForExport(state, { includeReferences: true }), + referenceManifest: buildWorkflowReferenceManifest(state.blocks), + }, + mappings: [{ kind: 'sandbox' as const, sourceId: 'source-sandbox-label', targetId: sandboxId }], + } +} + +async function countImports(name: string) { + return db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), eq(workflow.name, name))) +} + +describe('authorized mapped imports against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Import fixture', + email: `${userId}@workflow.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Mapped import fixture', + ownerId: userId, + billedAccountUserId: userId, + allowPersonalApiKeys: true, + }) + await db.insert(permissions).values({ + id: permissionId, + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + await db.insert(workspaceSandbox).values({ + id: sandboxId, + workspaceId, + name: 'Fixture JS', + language: 'javascript', + specHash: 'fixture-v1', + createdBy: userId, + }) + }) + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + it('commits one graph, inline tool, variables, receipt and outbox entry for concurrent retries', async () => { + const name = `import-${generateId()}` + const request = input(name) + const preview = await previewWorkflowImport.execute({ principal, input: request }) + expect(preview.ready).toBe(true) + const mutation = { + ...request, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + } + const results = await Promise.all( + Array.from({ length: 20 }, () => importWorkflow.execute({ principal, input: mutation })) + ) + expect(new Set(results.map((result) => result.operation?.operationId)).size).toBe(1) + const result = results[0] + expect(await countImports(name)).toHaveLength(1) + const [stored] = await db.select().from(workflow).where(eq(workflow.id, result.workflow.id)) + expect(stored.isDeployed).toBe(false) + expect(stored.variables).toMatchObject({ + [result.operation!.idMap!.answer]: { name: 'answer', value: 42 }, + }) + expect(result.operation!.idMap!.answer).not.toBe('answer') + const blocks = await db + .select() + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, result.workflow.id)) + expect(blocks).toHaveLength(2) + const [edge] = await db + .select() + .from(workflowEdges) + .where(eq(workflowEdges.workflowId, result.workflow.id)) + expect(edge.id).toBe(result.operation!.idMap!['source-edge']) + expect(edge.id).not.toBe('source-edge') + expect(edge.sourceBlockId).toBe(result.operation!.idMap!.fn) + expect(edge.targetBlockId).toBe(result.operation!.idMap!.agent) + expect(blocks.find((block) => block.type === 'function')?.subBlocks).toMatchObject({ + sandboxId: { value: sandboxId }, + }) + const tools = await db + .select() + .from(customTools) + .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, name))) + expect(tools).toHaveLength(1) + expect( + await db + .select() + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, mutation.requestId)) + ).toHaveLength(1) + expect( + await db + .select() + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, 'workspace.workflows.changed'), + sql`${outboxEvent.payload}->>'workspaceId' = ${workspaceId}` + ) + ) + ).toHaveLength(1) + await db + .update(workspaceSandbox) + .set({ specHash: 'changed-after-commit' }) + .where(eq(workspaceSandbox.id, sandboxId)) + expect( + (await importWorkflow.execute({ principal, input: mutation })).operation?.operationId + ).toBe(result.operation?.operationId) + await expect( + importWorkflow.execute({ principal, input: { ...mutation, name: `${name}-changed` } }) + ).rejects.toThrow('different inputs') + await db.delete(permissions).where(eq(permissions.id, permissionId)) + await expect(importWorkflow.execute({ principal, input: mutation })).rejects.toThrow() + await db.insert(permissions).values({ + id: permissionId, + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + }) + + it('refuses stale or unresolved bindings without any business writes', async () => { + const name = `stale-${generateId()}` + const request = input(name) + const preview = await previewWorkflowImport.execute({ principal, input: request }) + await db + .update(workspaceSandbox) + .set({ specHash: 'changed-before-apply' }) + .where(eq(workspaceSandbox.id, sandboxId)) + await expect( + importWorkflow.execute({ + principal, + input: { + ...request, + requestId: generateId(), + previewFingerprint: preview.previewFingerprint, + }, + }) + ).rejects.toThrow('stale') + expect(await countImports(name)).toHaveLength(0) + const unresolved = { ...request, mappings: [] } + const next = await previewWorkflowImport.execute({ principal, input: unresolved }) + expect(next.ready).toBe(false) + await expect( + importWorkflow.execute({ + principal, + input: { + ...unresolved, + requestId: generateId(), + previewFingerprint: next.previewFingerprint, + }, + }) + ).rejects.toThrow('configuration') + expect(await countImports(name)).toHaveLength(0) + }) + + it('refuses a resource injected as a dependent value', async () => { + const request = input(`invalid-${generateId()}`) + await expect( + previewWorkflowImport.execute({ + principal, + input: { + ...request, + dependentValues: [{ blockId: 'fn', subBlockKey: 'sandboxId', value: 'foreign-sandbox' }], + }, + }) + ).rejects.toThrow('not configurable') + }) +}) diff --git a/apps/sim/lib/workspaces/__integration__/pagination.integration.ts b/apps/sim/lib/workspaces/__integration__/pagination.integration.ts new file mode 100644 index 00000000000..ef15cfe20b0 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/pagination.integration.ts @@ -0,0 +1,167 @@ +import { db } from '@sim/db' +import { permissions, user, workspace, workspaceOperationReceipt } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { v2ForkChildrenQuerySchema } from '@/lib/api/contracts/v2/workspace-fork' +import { v2ListWorkspaceOperationsQuerySchema } from '@/lib/api/contracts/v2/workspace-operations' +import { listWorkspaceOperations } from '@/lib/workspaces/operations/application' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { listWorkspaceForkChildren } from '@/ee/workspace-forking/application/discovery' + +const userId = generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const principal = { kind: 'personal_api_key' as const, userId, keyId: generateId() } +const childIds = Array.from({ length: 4 }, () => generateId()).sort() +const operationIds = Array.from({ length: 4 }, () => generateId()).sort() +const timestamps = [ + '2026-09-09 12:34:56.123001', + '2026-09-09 12:34:56.123999', + '2026-09-09 12:34:56.123456', + '2026-09-09 12:34:56.123456', +] +const descendingIndices = [1, 3, 2, 0] + +describe('workspace pagination against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Pagination fixture', + email: `${userId}@workflow.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values( + [workspaceId, otherWorkspaceId].map((id) => ({ + id, + name: 'Pagination parent fixture', + ownerId: userId, + billedAccountUserId: userId, + allowPersonalApiKeys: true, + })) + ) + await db.insert(permissions).values( + [workspaceId, otherWorkspaceId].map((id) => ({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: id, + permissionType: 'admin' as const, + })) + ) + await db.insert(workspace).values( + childIds.map((id, index) => ({ + id, + name: `Pagination child ${index}`, + ownerId: userId, + billedAccountUserId: userId, + forkedFromWorkspaceId: workspaceId, + /** Bind PostgreSQL timestamps directly so fixture construction preserves microseconds. */ + createdAt: sql`${timestamps[index]}::timestamp`, + })) + ) + await db.insert(workspaceOperationReceipt).values( + operationIds.map((id, index) => { + const report: WorkspaceOperationReport = { + operationId: id, + requestId: generateId(), + workspaceId, + kind: 'workspace_fork', + applied: true, + status: 'completed', + resourceIds: [childIds[index]], + issues: [], + } + return { + id, + workspaceId, + requestId: report.requestId, + requestHash: 'pagination-fixture', + kind: report.kind, + report, + createdAt: sql`${timestamps[index]}::timestamp`, + } + }) + ) + }) + + afterAll(async () => { + await db + .delete(workspace) + .where(inArray(workspace.id, [...childIds, workspaceId, otherWorkspaceId])) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + it('lists every operation once in descending order across microsecond and identical-timestamp boundaries', async () => { + let cursor: string | undefined + for (const [pageIndex, rowIndex] of descendingIndices.entries()) { + const page = await listWorkspaceOperations.execute({ + principal, + input: { + workspaceId, + ...v2ListWorkspaceOperationsQuerySchema.parse({ limit: 1, cursor }), + }, + }) + expect(page.operations.map((operation) => operation.operationId)).toEqual([ + operationIds[rowIndex], + ]) + if (pageIndex === descendingIndices.length - 1) { + expect(page.nextCursor).toBeNull() + } else { + expect(page.nextCursor).toEqual(expect.any(String)) + } + cursor = page.nextCursor ?? undefined + } + }) + + it('refuses an operation cursor under another workspace the principal can access', async () => { + const first = await listWorkspaceOperations.execute({ + principal, + input: { workspaceId, limit: 1 }, + }) + expect(first.nextCursor).not.toBeNull() + await expect( + listWorkspaceOperations.execute({ + principal, + input: { workspaceId: otherWorkspaceId, limit: 1, cursor: first.nextCursor! }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) + + it('lists every fork child once in the supported descending order within one millisecond', async () => { + let cursor: string | undefined + for (const [pageIndex, rowIndex] of descendingIndices.entries()) { + const page = await listWorkspaceForkChildren.execute({ + principal, + input: { workspaceId, ...v2ForkChildrenQuerySchema.parse({ limit: 1, cursor }) }, + }) + expect(page.items.map((child) => child.id)).toEqual([childIds[rowIndex]]) + expect(page.items[0].createdAt).toBe('2026-09-09T12:34:56.123Z') + if (pageIndex === descendingIndices.length - 1) { + expect(page.nextCursor).toBeNull() + } else { + expect(page.nextCursor).toEqual(expect.any(String)) + } + cursor = page.nextCursor ?? undefined + } + }) + + it('refuses a fork child cursor under another parent the principal can access', async () => { + const query = v2ForkChildrenQuerySchema.parse({ limit: 1 }) + const first = await listWorkspaceForkChildren.execute({ + principal, + input: { workspaceId, ...query }, + }) + expect(first.nextCursor).not.toBeNull() + await expect( + listWorkspaceForkChildren.execute({ + principal, + input: { workspaceId: otherWorkspaceId, ...query, cursor: first.nextCursor! }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/workspaces/__integration__/receipts.integration.ts b/apps/sim/lib/workspaces/__integration__/receipts.integration.ts new file mode 100644 index 00000000000..055c9dc7985 --- /dev/null +++ b/apps/sim/lib/workspaces/__integration__/receipts.integration.ts @@ -0,0 +1,131 @@ +import { db } from '@sim/db' +import { user, workflow, workspace, workspaceOperationReceipt } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + findWorkspaceOperationReceipt, + insertWorkspaceOperationReceipt, + lockWorkspaceOperationRequest, + type WorkspaceOperationReport, + workflowOperationFingerprint, +} from '@/lib/workspaces/operations/receipts' + +const userId = generateId() +const workspaceId = generateId() + +describe('workspace receipts against PostgreSQL', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Workflow fixture', + email: `${userId}@workflow.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Workflow sync fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + }) + afterAll(async () => { + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + async function apply( + requestId: string, + payload: string, + failAt?: 'resource' | 'receipt' | 'oversize' + ) { + const requestHash = workflowOperationFingerprint({ payload }) + return db.transaction(async (tx) => { + await lockWorkspaceOperationRequest(tx, workspaceId, requestId) + const existing = await findWorkspaceOperationReceipt(tx, workspaceId, requestId, requestHash) + if (existing) return existing + const workflowId = generateId() + await tx.insert(workflow).values({ + id: workflowId, + workspaceId, + userId, + name: requestId, + lastSynced: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + if (failAt === 'resource') throw new Error('Injected failure after resource insert') + const report: WorkspaceOperationReport = { + operationId: generateId(), + requestId, + workspaceId, + kind: 'workflow_import', + applied: true, + status: 'completed', + resourceIds: [workflowId], + issues: + failAt === 'oversize' ? [{ code: 'fixture', message: 'x'.repeat(1024 * 1024) }] : [], + } + await insertWorkspaceOperationReceipt(tx, requestHash, report) + if (failAt === 'receipt') throw new Error('Injected failure after receipt insert') + return report + }) + } + + it('commits exactly one business mutation for 20 simultaneous retries', async () => { + const requestId = generateId() + const reports = await Promise.all(Array.from({ length: 20 }, () => apply(requestId, 'same'))) + expect(new Set(reports.map((report) => report.operationId)).size).toBe(1) + const resources = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.name, requestId)) + expect(resources).toHaveLength(1) + const receipts = await db + .select({ id: workspaceOperationReceipt.id }) + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + expect(receipts).toHaveLength(1) + }) + + it('rejects changed payloads under a concurrent request ID', async () => { + const requestId = generateId() + const results = await Promise.allSettled([ + apply(requestId, 'first'), + apply(requestId, 'second'), + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1) + }) + + for (const failAt of ['resource', 'receipt', 'oversize'] as const) { + it(`rolls back both resource and receipt after ${failAt} failure`, async () => { + const requestId = generateId() + await expect(apply(requestId, 'payload', failAt)).rejects.toThrow() + expect( + await db.select({ id: workflow.id }).from(workflow).where(eq(workflow.name, requestId)) + ).toHaveLength(0) + expect( + await db + .select({ id: workspaceOperationReceipt.id }) + .from(workspaceOperationReceipt) + .where(eq(workspaceOperationReceipt.requestId, requestId)) + ).toHaveLength(0) + expect((await apply(requestId, 'payload')).applied).toBe(true) + }) + } + + it('returns the stored result without repeating work when its resource was subsequently deleted', async () => { + const requestId = generateId() + const original = await apply(requestId, 'payload') + await db.delete(workflow).where(eq(workflow.id, original.resourceIds[0])) + expect(await apply(requestId, 'payload')).toEqual(original) + expect( + await db.select({ id: workflow.id }).from(workflow).where(eq(workflow.name, requestId)) + ).toHaveLength(0) + }) +}) diff --git a/apps/sim/lib/workspaces/operations/application.ts b/apps/sim/lib/workspaces/operations/application.ts new file mode 100644 index 00000000000..724441227a6 --- /dev/null +++ b/apps/sim/lib/workspaces/operations/application.ts @@ -0,0 +1,127 @@ +import { db } from '@sim/db' +import { workspaceOperationReceipt } from '@sim/db/schema' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { z } from 'zod' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + resolveActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { refreshWorkspaceOperation } from '@/lib/workspaces/operations/refresh' + +export const getWorkspaceOperation = defineAuthorizedWorkspaceUseCase< + typeof workspaceOperations.read, + { workspaceId: string; operationId: string }, + ActiveWorkspaceApplicationContext, + WorkspaceOperationReport +>({ + operation: workspaceOperations.read, + authorizationOptions: {}, + resolveContext: ({ input }: { input: { workspaceId: string; operationId: string } }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ input, context }) { + const report = await refreshWorkspaceOperation(context.workspaceId, input.operationId) + if (!report) throw new OrchestrationError('not_found', 'Operation not found') + return report + }, +}) + +const operationCursorSchema = z + .object({ + id: z.string().min(1).max(256), + createdAt: z + .string() + .max(64) + .regex(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$/), + workspaceId: z.string().max(256), + requestId: z.string().max(128).optional(), + }) + .strict() +interface ListWorkspaceOperationsInput { + workspaceId: string + requestId?: string + limit: number + cursor?: string +} + +export const listWorkspaceOperations = defineAuthorizedWorkspaceUseCase({ + operation: workspaceOperations.read, + authorizationOptions: {}, + resolveContext: ({ input }: { input: ListWorkspaceOperationsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ input, context }) { + let cursor: z.output | undefined + if (input.cursor) { + try { + cursor = operationCursorSchema.parse( + JSON.parse(Buffer.from(input.cursor, 'base64url').toString('utf8')) + ) + } catch { + throw new OrchestrationError('validation', 'Invalid operation cursor') + } + if (cursor.workspaceId !== context.workspaceId || cursor.requestId !== input.requestId) + throw new OrchestrationError( + 'validation', + 'Cursor does not match the requested operation filters' + ) + } + const candidates = await db + .select({ + id: workspaceOperationReceipt.id, + createdAt: sql`${workspaceOperationReceipt.createdAt}::text`, + bytes: sql`octet_length(${workspaceOperationReceipt.report}::text)`, + }) + .from(workspaceOperationReceipt) + .where( + and( + eq(workspaceOperationReceipt.workspaceId, context.workspaceId), + input.requestId ? eq(workspaceOperationReceipt.requestId, input.requestId) : undefined, + cursor + ? sql`(${workspaceOperationReceipt.createdAt}, ${workspaceOperationReceipt.id}) < (${cursor.createdAt}::timestamp, ${cursor.id})` + : undefined + ) + ) + .orderBy(desc(workspaceOperationReceipt.createdAt), desc(workspaceOperationReceipt.id)) + .limit(input.limit + 1) + let bytes = 0 + const selected = [] + for (const row of candidates.slice(0, input.limit)) { + if (selected.length && bytes + row.bytes > 2 * 1024 * 1024) break + selected.push(row) + bytes += row.bytes + } + const rows = selected.length + ? await db + .select({ report: workspaceOperationReceipt.report }) + .from(workspaceOperationReceipt) + .where( + and( + eq(workspaceOperationReceipt.workspaceId, context.workspaceId), + inArray( + workspaceOperationReceipt.id, + selected.map((row) => row.id) + ) + ) + ) + .orderBy(desc(workspaceOperationReceipt.createdAt), desc(workspaceOperationReceipt.id)) + : [] + const last = selected.at(-1) + return { + operations: rows.map((row) => row.report as WorkspaceOperationReport), + nextCursor: + last && candidates.length > selected.length + ? Buffer.from( + JSON.stringify({ + id: last.id, + createdAt: last.createdAt, + workspaceId: context.workspaceId, + requestId: input.requestId, + }) + ).toString('base64url') + : null, + } + }, +}) diff --git a/apps/sim/lib/workspaces/operations/operations.ts b/apps/sim/lib/workspaces/operations/operations.ts new file mode 100644 index 00000000000..eed91aca035 --- /dev/null +++ b/apps/sim/lib/workspaces/operations/operations.ts @@ -0,0 +1,15 @@ +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +export const workspaceOperations = { + /** + * permission-group-exempt: operation reports describe changes in the caller's accessible workspace. + */ + read: defineWorkspaceOperation({ + id: 'workspaces.operations.read', + oauthScope: 'api:read', + minimumRole: 'read', + workspaceApiKey: 'allow', + capability: 'none', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], + }), +} diff --git a/apps/sim/lib/workspaces/operations/outbox.ts b/apps/sim/lib/workspaces/operations/outbox.ts new file mode 100644 index 00000000000..3a735359819 --- /dev/null +++ b/apps/sim/lib/workspaces/operations/outbox.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { deferOutboxHandler, type OutboxHandlerRegistry } from '@/lib/core/outbox/service' +import { publishMcpToolServerChanges } from '@/lib/mcp/workflow-mcp-sync' +import { notifyWorkspaceWorkflowsChanged } from '@/lib/realtime/notify' +import { refreshWorkspaceOperation } from '@/lib/workspaces/operations/refresh' + +const operationSchema = z + .object({ workspaceId: z.string().min(1).max(256), operationId: z.string().min(1).max(256) }) + .strict() +const changedWorkspaceSchema = z.object({ workspaceId: z.string().min(1).max(256) }).strict() + +const serverChangesSchema = z + .object({ serverIds: z.array(z.string().min(1).max(256)).max(2000) }) + .strict() + +export const workspaceOperationOutboxHandlers = { + 'workspace.mcp.changed': async (payload) => { + await publishMcpToolServerChanges(serverChangesSchema.parse(payload).serverIds) + }, + 'workspace.operation.observe': async (payload) => { + const { workspaceId, operationId } = operationSchema.parse(payload) + const report = await refreshWorkspaceOperation(workspaceId, operationId) + if (report && !report.completionRecorded) + return deferOutboxHandler('Waiting for workspace operation effects', 5000, false) + }, + 'workspace.workflows.changed': async (payload) => { + const { workspaceId } = changedWorkspaceSchema.parse(payload) + await notifyWorkspaceWorkflowsChanged(workspaceId) + }, +} satisfies OutboxHandlerRegistry diff --git a/apps/sim/lib/workspaces/operations/receipts.ts b/apps/sim/lib/workspaces/operations/receipts.ts new file mode 100644 index 00000000000..5403aab1a82 --- /dev/null +++ b/apps/sim/lib/workspaces/operations/receipts.ts @@ -0,0 +1,173 @@ +import { createHash } from 'node:crypto' +import { db } from '@sim/db' +import { workspaceOperationReceipt } from '@sim/db/schema' +import { sortObjectKeysDeep } from '@sim/utils/object' +import { and, eq, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import type { DeploymentOperationStatus } from '@/lib/workflows/deployment-lifecycle' +import type { CreateForkResult } from '@/ee/workspace-forking/lib/create-fork' +import type { PromoteForkResult } from '@/ee/workspace-forking/lib/promote/promote' + +export type WorkspaceOperationKind = + | 'workflow_import' + | 'workspace_fork' + | 'workspace_push' + | 'workspace_pull' +export type WorkspaceOperationStatus = + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + +export interface WorkspaceOperationIssue { + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string +} + +export interface WorkspaceOperationReport { + operationId: string + requestId: string + workspaceId: string + kind: WorkspaceOperationKind + applied: true + status: WorkspaceOperationStatus + resourceIds: string[] + issues: WorkspaceOperationIssue[] + idMap?: Record + deploymentOperationIds?: string[] + effectEventIds?: string[] + triggerUrlChanges?: PromoteForkResult['triggerUrlChanges'] + completionRecorded?: boolean + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: DeploymentOperationStatus + ready: boolean + pendingComponents: string[] + }> + backgroundWorkId?: string + contentOutboxEventId?: string + copyProgress?: { status: 'pending' | 'completed' | 'failed'; copied: number; failed: number } + forkResult?: Omit + syncResult?: Omit + importedWorkflow?: { + id: string + name: string + description: string | null + workspaceId: string + folderId: string | null + folderPath: string + sortOrder: number + createdAt: string + updatedAt: string + } +} + +export class WorkspaceOperationConflict extends OrchestrationError { + constructor( + message: string, + readonly details: Record + ) { + super('conflict', message) + } +} + +/** Hash only normalized domain input, excluding transport request IDs and timestamps. */ +export function workflowOperationFingerprint(value: unknown): string { + return createHash('sha256') + .update(JSON.stringify(sortObjectKeysDeep(value))) + .digest('hex') +} + +/** Serializes absent receipts as well as existing ones without a separately committed claim. */ +export async function lockWorkspaceOperationRequest( + tx: DbOrTx, + workspaceId: string, + requestId: string +): Promise { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['workspace-operation', workspaceId, requestId])}, 0))` + ) +} + +/** Call only after the application use case has authorized the current principal. */ +export async function findWorkspaceOperationReceipt( + executor: DbOrTx, + workspaceId: string, + requestId: string, + requestHash: string +): Promise { + const [row] = await executor + .select({ + requestHash: workspaceOperationReceipt.requestHash, + report: workspaceOperationReceipt.report, + }) + .from(workspaceOperationReceipt) + .where( + and( + eq(workspaceOperationReceipt.workspaceId, workspaceId), + eq(workspaceOperationReceipt.requestId, requestId) + ) + ) + .limit(1) + if (!row) return null + if (row.requestHash !== requestHash) { + throw new WorkspaceOperationConflict('requestId was already used with different inputs', { + requestId, + reason: 'request_id_reused', + }) + } + return row.report as WorkspaceOperationReport +} + +/** Call after authorization; a concurrent identical commit wins over a stale preflight refusal. */ +export async function withWorkspaceOperationReplay( + scope: { workspaceId: string; requestId: string; requestHash: string }, + replay: (report: WorkspaceOperationReport) => T, + apply: () => Promise +): Promise { + const existing = await findWorkspaceOperationReceipt( + db, + scope.workspaceId, + scope.requestId, + scope.requestHash + ) + if (existing) return replay(existing) + try { + return await apply() + } catch (error) { + const committed = await findWorkspaceOperationReceipt( + db, + scope.workspaceId, + scope.requestId, + scope.requestHash + ) + if (committed) return replay(committed) + throw error + } +} + +/** The report stores resource identities and outcomes, never graph state or credentials. */ +export async function insertWorkspaceOperationReceipt( + tx: DbOrTx, + requestHash: string, + report: WorkspaceOperationReport +): Promise { + if (Buffer.byteLength(JSON.stringify(report), 'utf8') > 1024 * 1024) { + throw new OrchestrationError('payload_too_large', 'Operation report exceeds 1 MiB') + } + await tx.insert(workspaceOperationReceipt).values({ + id: report.operationId, + workspaceId: report.workspaceId, + requestId: report.requestId, + requestHash, + kind: report.kind, + report, + }) +} diff --git a/apps/sim/lib/workspaces/operations/refresh.test.ts b/apps/sim/lib/workspaces/operations/refresh.test.ts new file mode 100644 index 00000000000..7762a394bfe --- /dev/null +++ b/apps/sim/lib/workspaces/operations/refresh.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' +import { refreshWorkspaceOperation } from '@/lib/workspaces/operations/refresh' + +const { receiptTable } = vi.hoisted(() => ({ + receiptTable: { + id: 'workspaceOperationReceipt.id', + workspaceId: 'workspaceOperationReceipt.workspaceId', + report: 'workspaceOperationReceipt.report', + }, +})) +vi.mock('@sim/db/schema', () => ({ ...schemaMock, workspaceOperationReceipt: receiptTable })) + +function report(overrides: Partial = {}): WorkspaceOperationReport { + return { + operationId: 'operation', + requestId: 'request', + workspaceId: 'workspace', + kind: 'workspace_push', + applied: true, + status: 'processing', + resourceIds: ['workflow'], + issues: [], + ...overrides, + } +} + +function queueReport(value: WorkspaceOperationReport): void { + queueTableRows(receiptTable, [{ report: value }]) +} + +describe('refreshWorkspaceOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it.each([null, {}, { triggers: { status: 'pending', updatedAt: '2026-09-09T00:00:00Z' } }])( + 'fails terminal deployment readiness that cannot be verified: %j', + async (readiness) => { + queueReport(report({ deploymentOperationIds: ['deployment'] })) + queueTableRows(schemaMock.workflowDeploymentOperation, [ + { id: 'deployment', workflowId: 'workflow', version: 1, status: 'active', readiness }, + ]) + const result = await refreshWorkspaceOperation('workspace', 'operation') + expect(result).toMatchObject({ + status: 'failed', + completionRecorded: true, + deployments: [{ ready: false }], + issues: [{ code: 'deployment_readiness_invalid' }], + }) + } + ) + + it('keeps a failed effect visible without freezing another pending effect', async () => { + const current = report({ effectEventIds: ['failed', 'pending'] }) + queueReport(current) + queueTableRows(schemaMock.outboxEvent, [ + { id: 'failed', status: 'dead_letter' }, + { id: 'pending', status: 'processing' }, + ]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + status: 'processing', + completionRecorded: false, + issues: [{ code: 'follow_up_failed' }], + }) + queueReport(current) + queueTableRows(schemaMock.outboxEvent, [ + { id: 'failed', status: 'dead_letter' }, + { id: 'pending', status: 'completed' }, + ]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + status: 'failed', + completionRecorded: true, + issues: [{ code: 'follow_up_failed' }], + }) + expect(current.issues).toHaveLength(1) + }) + + it('waits for a real deployment attempt even when another effect has failed', async () => { + queueReport(report({ deploymentOperationIds: ['deployment'], effectEventIds: ['failed'] })) + queueTableRows(schemaMock.workflowDeploymentOperation, [ + { + id: 'deployment', + workflowId: 'workflow', + version: 1, + status: 'preparing', + readiness: { triggers: { status: 'pending', updatedAt: '2026-09-09T00:00:00Z' } }, + }, + ]) + queueTableRows(schemaMock.outboxEvent, [{ id: 'failed', status: 'dead_letter' }]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + status: 'processing', + completionRecorded: false, + }) + }) + + it('allows an unfinished deployment to establish readiness before deciding its terminal result', async () => { + const current = report({ deploymentOperationIds: ['deployment'] }) + queueReport(current) + queueTableRows(schemaMock.workflowDeploymentOperation, [ + { + id: 'deployment', + workflowId: 'workflow', + version: 1, + status: 'preparing', + readiness: null, + }, + ]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + status: 'processing', + completionRecorded: false, + issues: [], + deployments: [{ ready: false }], + }) + queueReport(current) + queueTableRows(schemaMock.workflowDeploymentOperation, [ + { + id: 'deployment', + workflowId: 'workflow', + version: 1, + status: 'active', + readiness: { triggers: { status: 'ready', updatedAt: '2026-09-09T00:00:00Z' } }, + }, + ]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + status: 'completed', + completionRecorded: true, + issues: [], + deployments: [{ ready: true }], + }) + }) + + it('records an abandoned copy as failed while preserving the committed receipt', async () => { + queueReport( + report({ + copyProgress: { status: 'pending', copied: 2, failed: 0 }, + contentOutboxEventId: 'copy', + }) + ) + queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }]) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toMatchObject({ + applied: true, + status: 'failed', + completionRecorded: true, + copyProgress: { status: 'failed', copied: 2, failed: 1 }, + }) + }) + + it('returns a completed receipt without rereading effects or changing it', async () => { + const current = report({ status: 'completed', completionRecorded: true }) + queueReport(current) + expect(await refreshWorkspaceOperation('workspace', 'operation')).toEqual(current) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspaces/operations/refresh.ts b/apps/sim/lib/workspaces/operations/refresh.ts new file mode 100644 index 00000000000..3bc55351e88 --- /dev/null +++ b/apps/sim/lib/workspaces/operations/refresh.ts @@ -0,0 +1,161 @@ +import { db } from '@sim/db' +import { outboxEvent, workflowDeploymentOperation, workspaceOperationReceipt } from '@sim/db/schema' +import { truncate } from '@sim/utils/string' +import { and, eq, inArray } from 'drizzle-orm' +import { + isDeploymentOperationStatus, + parseDeploymentReadiness, +} from '@/lib/workflows/deployment-lifecycle' +import type { WorkspaceOperationReport } from '@/lib/workspaces/operations/receipts' + +/** Projects the exact admitted attempts, preserving their terminal outcome in the lifetime receipt. */ +export async function refreshWorkspaceOperation( + workspaceId: string, + operationId: string +): Promise { + return db.transaction(async (tx) => { + const [row] = await tx + .select({ report: workspaceOperationReceipt.report }) + .from(workspaceOperationReceipt) + .where( + and( + eq(workspaceOperationReceipt.workspaceId, workspaceId), + eq(workspaceOperationReceipt.id, operationId) + ) + ) + .for('update') + .limit(1) + if (!row) return null + const report = row.report as WorkspaceOperationReport + if (report.completionRecorded) return report + const ids = report.deploymentOperationIds ?? [] + const attempts = ids.length + ? await tx + .select({ + id: workflowDeploymentOperation.id, + workflowId: workflowDeploymentOperation.workflowId, + status: workflowDeploymentOperation.status, + version: workflowDeploymentOperation.version, + readiness: workflowDeploymentOperation.componentReadiness, + errorCode: workflowDeploymentOperation.errorCode, + errorMessage: workflowDeploymentOperation.errorMessage, + }) + .from(workflowDeploymentOperation) + .where(inArray(workflowDeploymentOperation.id, ids)) + : [] + const addIssue = (code: string, message: string, workflowId?: string) => { + if (!report.issues.some((issue) => issue.code === code && issue.workflowId === workflowId)) + report.issues.push({ + code, + message: truncate(message, 2048), + ...(workflowId ? { workflowId } : {}), + }) + } + if (attempts.length !== ids.length) + addIssue( + 'deployment_receipt_missing', + 'An admitted deployment is no longer available; its readiness could not be verified' + ) + report.deployments = attempts.map((attempt) => { + if (!isDeploymentOperationStatus(attempt.status)) + throw new Error('Unknown stored deployment status') + if (attempt.status === 'failed' || attempt.status === 'superseded') + addIssue( + 'deployment_failed', + attempt.errorMessage ?? `The admitted deployment was ${attempt.status}`, + attempt.workflowId + ) + const components = parseDeploymentReadiness(attempt.readiness) + const validComponents = components && Object.keys(components).length > 0 + const pendingComponents = Object.entries( + validComponents ? components : { unknown: { status: 'pending' } } + ) + .filter(([, readiness]) => readiness.status !== 'ready') + .map(([name]) => name) + if (attempt.status === 'active' && pendingComponents.length > 0) + addIssue( + 'deployment_readiness_invalid', + 'Deployment readiness could not be verified', + attempt.workflowId + ) + return { + operationId: attempt.id, + workflowId: attempt.workflowId, + version: attempt.version, + status: attempt.status, + ready: attempt.status === 'active' && pendingComponents.length === 0, + pendingComponents, + } + }) + if (report.copyProgress?.status === 'pending') { + const [event] = report.contentOutboxEventId + ? await tx + .select({ status: outboxEvent.status }) + .from(outboxEvent) + .where(eq(outboxEvent.id, report.contentOutboxEventId)) + .limit(1) + : [] + if (!event || event.status === 'dead_letter' || event.status === 'completed') { + report.copyProgress = { + ...report.copyProgress, + status: 'failed', + failed: Math.max(1, report.copyProgress.failed), + } + addIssue( + 'resource_copy_failed', + 'Resource copy stopped before recording completion; the workspace changes remain committed' + ) + } + } + const effectIds = report.effectEventIds ?? [] + const effects = effectIds.length + ? await tx + .select({ id: outboxEvent.id, status: outboxEvent.status }) + .from(outboxEvent) + .where(inArray(outboxEvent.id, effectIds)) + : [] + const effectFailed = + effects.length !== effectIds.length || effects.some((event) => event.status === 'dead_letter') + if (effectFailed) + addIssue( + 'follow_up_failed', + 'An admitted background effect failed; the workspace changes remain committed' + ) + const effectsPending = effects.some( + (event) => event.status === 'pending' || event.status === 'processing' + ) + const pending = + effectsPending || + report.copyProgress?.status === 'pending' || + report.deployments.some( + (attempt) => attempt.status === 'preparing' || attempt.status === 'activating' + ) + const failed = + effectFailed || + report.copyProgress?.status === 'failed' || + report.issues.some((issue) => + [ + 'deployment_failed', + 'deployment_admission_failed', + 'deployment_receipt_missing', + 'resource_copy_failed', + 'deployment_readiness_invalid', + ].includes(issue.code) + ) + report.status = pending + ? 'processing' + : failed + ? 'failed' + : report.issues.some((issue) => issue.code === 'required_configuration') + ? 'requires_configuration' + : report.issues.length + ? 'completed_with_warnings' + : 'completed' + report.completionRecorded = !pending + await tx + .update(workspaceOperationReceipt) + .set({ report, updatedAt: new Date() }) + .where(eq(workspaceOperationReceipt.id, operationId)) + return report + }) +} diff --git a/apps/sim/vitest.workflows-integration.config.ts b/apps/sim/vitest.workflows-integration.config.ts new file mode 100644 index 00000000000..2c6d3540d7b --- /dev/null +++ b/apps/sim/vitest.workflows-integration.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { tsconfigPaths: true, alias: { '@': fileURLToPath(new URL('.', import.meta.url)) } }, + css: { postcss: {} }, + test: { + environment: 'node', + include: ['lib/workspaces/__integration__/*.integration.ts'], + setupFiles: ['vitest.workflows-integration.setup.ts'], + pool: 'forks', + fileParallelism: false, + maxWorkers: 1, + testTimeout: 30000, + hookTimeout: 30000, + }, +}) diff --git a/apps/sim/vitest.workflows-integration.setup.ts b/apps/sim/vitest.workflows-integration.setup.ts new file mode 100644 index 00000000000..e2dc34d45af --- /dev/null +++ b/apps/sim/vitest.workflows-integration.setup.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { afterAll } from 'vitest' + +const databaseUrl = process.env.WORKFLOW_TEST_DATABASE_URL +if (!databaseUrl) + throw new Error('WORKFLOW_TEST_DATABASE_URL must name a disposable local test database') +const target = new URL(databaseUrl) +if ( + !['127.0.0.1', 'localhost'].includes(target.hostname) || + !target.pathname.startsWith('/sim_workflow_test') +) { + throw new Error('Workflow integration tests refuse nonlocal or nonfixture databases') +} +const environmentSource = readFileSync(new URL('./lib/core/config/env.ts', import.meta.url), 'utf8') +for (const entry of environmentSource.matchAll(/^\s+([A-Z][A-Z0-9_]*)\s*:/gm)) + delete process.env[entry[1]] +for (const key of Object.keys(process.env)) { + if ( + key.startsWith('DATABASE_URL') || + key.startsWith('DATABASE_REPLICA_URL') || + key === 'MIGRATION_DATABASE_URL' + ) + delete process.env[key] +} +Object.assign(process.env, { + NODE_ENV: 'test', + DATABASE_URL: databaseUrl, + DB_TX_TRIPWIRE: 'throw', + NEXT_PUBLIC_APP_URL: 'http://127.0.0.1:3000', + INTERNAL_API_BASE_URL: 'http://127.0.0.1:3000', + NEXT_PUBLIC_FORCE_HOSTED: 'false', + BILLING_ENABLED: 'false', + FORKING_ENABLED: 'true', + ACCESS_CONTROL_ENABLED: 'true', + STORAGE_PROVIDER: 'local', + OCR_PROVIDER: 'local', + DISABLE_AUTH: 'false', + DISABLE_TELEMETRY: 'true', + BETTER_AUTH_SECRET: 'workflow-integration-fixture-authentication-secret', + INTERNAL_API_SECRET: 'workflow-integration-fixture-internal-secret', + ENCRYPTION_KEY: '0'.repeat(64), + API_ENCRYPTION_KEY: '1'.repeat(64), +}) + +/** Local stand-in for the realtime process; deployment still performs its real HTTP notification. */ +const realtime = createServer(async (request, response) => { + for await (const _chunk of request) { + } + if (request.headers['x-api-key'] !== process.env.INTERNAL_API_SECRET) { + response.writeHead(401).end() + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ success: true })) +}) +await new Promise((resolve) => realtime.listen(0, '127.0.0.1', resolve)) +const address = realtime.address() +if (!address || typeof address === 'string') throw new Error('Realtime fixture failed to bind') +process.env.SOCKET_SERVER_URL = `http://127.0.0.1:${address.port}` +afterAll( + () => + new Promise((resolve, reject) => { + realtime.close((error) => (error ? reject(error) : resolve())) + realtime.closeAllConnections() + }) +) diff --git a/package.json b/package.json index 3377e1f5aba..080c1582317 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "agent-stream-docs:generate": "bun run scripts/sync-agent-stream-docs.ts", "agent-stream-docs:check": "bun run scripts/sync-agent-stream-docs.ts --check", "prepare": "bun husky", + "test:workflow-sync": "bun --no-env-file scripts/test-workflow-sync.ts", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts", "test:scripts": "vitest run --config vitest.scripts.config.ts" diff --git a/packages/db/migrations/0337_colossal_the_renegades.sql b/packages/db/migrations/0337_colossal_the_renegades.sql new file mode 100644 index 00000000000..02c2cb0ae2f --- /dev/null +++ b/packages/db/migrations/0337_colossal_the_renegades.sql @@ -0,0 +1,15 @@ +ALTER TYPE "public"."workspace_fork_resource_type" ADD VALUE 'sandbox';--> statement-breakpoint +CREATE TABLE "workspace_operation_receipt" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "request_id" text NOT NULL, + "request_hash" text NOT NULL, + "kind" text NOT NULL, + "report" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_operation_receipt" ADD CONSTRAINT "workspace_operation_receipt_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "workspace_operation_receipt_request_unique" ON "workspace_operation_receipt" USING btree ("workspace_id","request_id");--> statement-breakpoint +CREATE INDEX "workspace_operation_receipt_workspace_created_idx" ON "workspace_operation_receipt" USING btree ("workspace_id","created_at","id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0337_snapshot.json b/packages/db/migrations/meta/0337_snapshot.json new file mode 100644 index 00000000000..439ec20b784 --- /dev/null +++ b/packages/db/migrations/meta/0337_snapshot.json @@ -0,0 +1,26041 @@ +{ + "id": "52816097-17cd-473b-975a-f7b08169445d", + "prevId": "2430ddbb-9534-4a3d-bbd1-63635d0c0d29", + "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 + }, + "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_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_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": {} + }, + "embedding_vector_hnsw_idx": { + "name": "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 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "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_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", + "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", + "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_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_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 + }, + "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" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "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_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.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_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": true + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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)" + } + }, + "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 + }, + "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_org_domain_unique": { + "name": "sso_provider_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(regexp_replace(btrim(\"domain\"), '^\\*\\.', ''))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sso_provider\".\"organization_id\" is not null", + "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 + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "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()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "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'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "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'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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_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_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_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": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": "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_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", + "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 da853d75730..660164f99d0 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2353,6 +2353,13 @@ "when": 1788980365048, "tag": "0336_knowledge_processing_recovery", "breakpoints": true + }, + { + "idx": 337, + "version": "7", + "when": 1788986020651, + "tag": "0337_colossal_the_renegades", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a1a0a71c75d..89375ccdb66 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1951,6 +1951,7 @@ export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_typ 'custom_block', 'custom_tool', 'skill', + 'sandbox', ]) export const workspaceForkResourceMap = pgTable( @@ -2168,6 +2169,34 @@ export const backgroundWorkStatus = pgTable( }) ) +/** Workspace-lifetime mutation deduplication and bounded, durable operation reports. */ +export const workspaceOperationReceipt = pgTable( + 'workspace_operation_receipt', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + requestId: text('request_id').notNull(), + requestHash: text('request_hash').notNull(), + kind: text('kind').notNull(), + report: jsonb('report').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + requestUnique: uniqueIndex('workspace_operation_receipt_request_unique').on( + table.workspaceId, + table.requestId + ), + workspaceCreatedIdx: index('workspace_operation_receipt_workspace_created_idx').on( + table.workspaceId, + table.createdAt, + table.id + ), + }) +) + export const workspaceFile = pgTable( 'workspace_file', { diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 420739760b3..408843777d5 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -9,6 +9,7 @@ import { attachResourceDirectoryCommands } from './resource-directory' import { attachTableImport } from './tables-import' import { attachWorkflowRunFollow } from './workflow-run-follow' import { attachWorkflowRunWait } from './workflow-run-wait' +import { attachWorkspaceOperationWait } from './workspace-operation-wait' function group(program: Command, name: string): Command { const existing = program.commands.find((command) => command.name() === name) @@ -62,6 +63,8 @@ export function attachProtocolCommands(program: Command): void { attachWorkflowRunFollow(workflows) attachWorkflowRunWait(group(workflows, 'runs')) + attachWorkspaceOperationWait(group(group(program, 'workspaces'), 'operations')) + attachLogsFollow(group(program, 'logs')) attachChat(program) diff --git a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts new file mode 100644 index 00000000000..60768490035 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GetWorkspaceOperationResponse } from '../../generated/v2-api' +import { SimApiError, SimClient } from '../../http/client' +import { + assertWorkspaceOperationOutcome, + waitWorkspaceOperation, + workspaceWaitTimeout, +} from './workspace-operation-wait' + +const report: GetWorkspaceOperationResponse['data'] = { + operationId: 'operation-1', + requestId: 'stable-request', + workspaceId: 'ws-1', + kind: 'workspace_push', + applied: true, + status: 'processing', + resourceIds: ['workflow-1'], + issues: [], +} + +function fixtureClient() { + return new SimClient({ + name: 'fixture', + endpoint: 'https://fixture.invalid', + authProfile: 'fixture', + apiKey: 'fixture-key', + oauth: null, + workspaceId: 'ws-1', + output: 'json', + sources: { endpoint: 'default', credential: 'env', workspaceId: 'env', output: 'default' }, + }) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('workspace operation waiting', () => { + it('waits for the admitted operation to finish without sending a mutation', async () => { + vi.useFakeTimers() + const client = fixtureClient() + const request = vi + .spyOn(client, 'request') + .mockResolvedValueOnce({ data: report }) + .mockResolvedValueOnce({ data: { ...report, status: 'completed' } }) + const pending = waitWorkspaceOperation(client, 'ws-1', 'operation-1', 60) + await vi.runAllTimersAsync() + expect((await pending).report.status).toBe('completed') + expect( + request.mock.calls.every( + ([path, options]) => path.endsWith('/operations/operation-1') && !options?.method + ) + ).toBe(true) + }) + + it('retains the operation ID and timeout exit code even before the first response', async () => { + vi.useFakeTimers() + const client = fixtureClient() + vi.spyOn(client, 'request').mockImplementation(async () => { + vi.setSystemTime(Date.now() + 2000) + throw new SimApiError('Request cancelled', 0) + }) + await expect(waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1)).rejects.toMatchObject({ + exitCode: 4, + code: 'OPERATION_WAIT_TIMEOUT', + details: { operationId: 'operation-1', workspaceId: 'ws-1' }, + }) + }) + + it('retains a committed receipt when waiting times out', async () => { + vi.useFakeTimers() + const client = fixtureClient() + vi.spyOn(client, 'request').mockImplementation(async () => { + vi.setSystemTime(Date.now() + 2000) + throw new SimApiError('Request cancelled', 0) + }) + const result = await waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1, report) + expect(result).toEqual({ report, timedOut: true }) + expect(() => assertWorkspaceOperationOutcome(result.report, result.timedOut)).toThrow( + expect.objectContaining({ + exitCode: 4, + details: expect.objectContaining({ requestId: report.requestId, applied: true }), + }) + ) + }) + + it.each([ + ['requires_configuration', 3], + ['failed', 1], + ] as const)('returns a nonzero outcome for committed %s', (status, exitCode) => { + expect(() => assertWorkspaceOperationOutcome({ ...report, status })).toThrow( + expect.objectContaining({ exitCode, details: expect.objectContaining({ applied: true }) }) + ) + }) + + it('rejects mismatched identities even in an initially complete receipt', async () => { + await expect( + waitWorkspaceOperation(fixtureClient(), 'other-workspace', 'operation-1', 1, { + ...report, + status: 'completed', + }) + ).rejects.toMatchObject({ + code: 'INVALID_OPERATION_RECEIPT', + details: { workspaceId: 'other-workspace', operationId: 'operation-1' }, + }) + }) + + it('preserves IDs on a failed status read', async () => { + const client = fixtureClient() + vi.spyOn(client, 'request').mockRejectedValue(new SimApiError('Disconnected', 0)) + await expect( + waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1, report) + ).rejects.toMatchObject({ + details: { operationId: 'operation-1', requestId: 'stable-request', applied: true }, + }) + }) + + it.each([new Error('Body read failed'), 'body unavailable'])( + 'normalizes unexpected status errors and retains the committed receipt', + async (failure) => { + const client = fixtureClient() + const request = vi.spyOn(client, 'request').mockRejectedValue(failure) + await expect( + waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1, report) + ).rejects.toMatchObject({ + name: 'SimApiError', + code: 'OPERATION_STATUS_UNAVAILABLE', + exitCode: 1, + details: { + workspaceId: 'ws-1', + operationId: 'operation-1', + requestId: 'stable-request', + applied: true, + }, + }) + expect(request).toHaveBeenCalledTimes(1) + } + ) + + it('retains the operation identity when the first status read throws unexpectedly', async () => { + const client = fixtureClient() + vi.spyOn(client, 'request').mockRejectedValue(new Error('Disconnected')) + await expect(waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1)).rejects.toMatchObject({ + code: 'OPERATION_STATUS_UNAVAILABLE', + details: { workspaceId: 'ws-1', operationId: 'operation-1' }, + }) + }) + + it.each([ + null, + { data: { ...report, requestId: 'unrelated-request', operationId: 'unrelated-operation' } }, + ])('keeps the last trusted receipt when polling returns invalid data', async (response) => { + const client = fixtureClient() + vi.spyOn(client, 'request').mockResolvedValue(response) + await expect( + waitWorkspaceOperation(client, 'ws-1', 'operation-1', 1, report) + ).rejects.toMatchObject({ + name: 'SimApiError', + details: { + workspaceId: 'ws-1', + operationId: 'operation-1', + requestId: 'stable-request', + applied: true, + }, + }) + }) + + it.each(['', -1, Number.POSITIVE_INFINITY, Number.NaN])( + 'rejects invalid timeout %s before sending a request', + (timeout) => { + expect(() => workspaceWaitTimeout(timeout)).toThrow('--wait-timeout') + } + ) +}) diff --git a/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts new file mode 100644 index 00000000000..17d0b54dcec --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/workspace-operation-wait.ts @@ -0,0 +1,185 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import { CLI_CONTRACT } from '../../contract/commands' +import { type GetWorkspaceOperationResponse, V2_OPERATIONS } from '../../generated/v2-api' +import { sleep } from '../../helpers' +import { resolvePath, SimApiError, type SimClient } from '../../http/client' +import { renderResult } from '../../runtime/result' + +type WorkspaceOperation = GetWorkspaceOperationResponse['data'] +const EXIT_CODES = { + completed: 0, + completed_with_warnings: 0, + requires_configuration: 3, + failed: 1, + processing: 0, +} as const + +export function readWorkspaceOperation(raw: unknown): WorkspaceOperation { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + throw new SimApiError('The API returned no workspace operation receipt', 0) + const value = 'data' in raw ? raw.data : raw + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + !('status' in value) || + typeof value.status !== 'string' || + !Object.hasOwn(EXIT_CODES, value.status) || + !('operationId' in value) || + typeof value.operationId !== 'string' || + !('requestId' in value) || + typeof value.requestId !== 'string' || + !('workspaceId' in value) || + typeof value.workspaceId !== 'string' || + !('applied' in value) || + value.applied !== true + ) { + throw new SimApiError('The API returned an invalid workspace operation receipt', 0) + } + return value as WorkspaceOperation +} + +export function workspaceWaitTimeout(raw: unknown): number { + if (raw === undefined) return 3600 + const seconds = typeof raw === 'string' && raw.trim() === '' ? Number.NaN : Number(raw) + if (!Number.isFinite(seconds) || seconds < 0) + throw new SimApiError( + '--wait-timeout must be a non-negative number of seconds (0 waits indefinitely)', + 0 + ) + return seconds +} + +export function assertWorkspaceOperationOutcome( + report: WorkspaceOperation, + timedOut = false +): void { + const exitCode = timedOut ? 4 : EXIT_CODES[report.status] + if (!exitCode) return + throw new SimApiError( + timedOut + ? 'Waiting timed out; reconcile using the same operation or request ID' + : report.status === 'requires_configuration' + ? 'The operation committed and requires destination configuration' + : 'The operation committed, but follow-up work failed', + 0, + timedOut ? 'OPERATION_WAIT_TIMEOUT' : report.status.toUpperCase(), + { + operationId: report.operationId, + requestId: report.requestId, + workspaceId: report.workspaceId, + applied: report.applied, + status: report.status, + issues: report.issues, + }, + exitCode + ) +} + +/** Polls only the existing operation; uncertain mutations are never retried with a new ID. */ +export async function waitWorkspaceOperation( + client: SimClient, + workspaceId: string, + operationId: string, + timeoutSeconds: number, + initial?: WorkspaceOperation +): Promise<{ report: WorkspaceOperation; timedOut: boolean }> { + const deadline = + timeoutSeconds === 0 ? Number.POSITIVE_INFINITY : Date.now() + timeoutSeconds * 1000 + const path = resolvePath(V2_OPERATIONS.getWorkspaceOperation.path, { workspaceId, operationId }) + let report = initial + let delay = 1000 + for (;;) { + if (report && (report.workspaceId !== workspaceId || report.operationId !== operationId)) + throw new SimApiError( + 'Operation status response has a different identity', + 0, + 'INVALID_OPERATION_RECEIPT', + { workspaceId, operationId } + ) + if (report && report.status !== 'processing') return { report, timedOut: false } + const remaining = deadline - Date.now() + if (remaining <= 0) { + if (report) return { report, timedOut: true } + throw new SimApiError( + 'Waiting timed out before a status response; reconcile using the same operation ID', + 0, + 'OPERATION_WAIT_TIMEOUT', + { workspaceId, operationId }, + 4 + ) + } + try { + const nextReport = readWorkspaceOperation( + await client.request(path, { + signal: Number.isFinite(remaining) + ? AbortSignal.timeout(Math.max(1, Math.ceil(remaining))) + : undefined, + }) + ) + if (nextReport.workspaceId !== workspaceId || nextReport.operationId !== operationId) + throw new SimApiError( + 'Operation status response has a different identity', + 0, + 'INVALID_OPERATION_RECEIPT' + ) + report = nextReport + } catch (error) { + if (Date.now() >= deadline) { + if (report) return { report, timedOut: true } + throw new SimApiError( + 'Waiting timed out before a status response; reconcile using the same operation ID', + 0, + 'OPERATION_WAIT_TIMEOUT', + { workspaceId, operationId }, + 4 + ) + } + const failure = + error instanceof SimApiError + ? error + : new SimApiError('Unable to read operation status', 0, 'OPERATION_STATUS_UNAVAILABLE') + throw new SimApiError( + failure.message, + failure.status, + failure.code, + { + cause: failure.details, + workspaceId, + operationId, + requestId: report?.requestId, + applied: report?.applied, + }, + failure.exitCode + ) + } + if (report.status !== 'processing') return { report, timedOut: false } + await sleep(Math.min(delay, Math.max(0, deadline - Date.now()))) + delay = Math.min(10000, delay * 2) + } +} + +export function attachWorkspaceOperationWait(operations: Command): void { + operations + .command('wait') + .argument('', 'Operation ID returned by import, fork, push, or pull') + .allowExcessArguments(false) + .description( + 'Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout' + ) + .option('--wait-timeout ', 'Maximum total wait (default 3600; 0 waits indefinitely)') + .action(async (operationId: string, options: { waitTimeout?: string }, command: Command) => { + const timeout = workspaceWaitTimeout(options.waitTimeout) + const { client, profile } = clientFrom(command) + const workspaceId = client.requireWorkspace() + const result = await waitWorkspaceOperation(client, workspaceId, operationId, timeout) + renderResult( + 'getWorkspaceOperation', + profile.output, + result.report, + CLI_CONTRACT.getWorkspaceOperation ?? {} + ) + assertWorkspaceOperationOutcome(result.report, result.timedOut) + }) +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index f6a75695e53..5223eedf095 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -694,7 +694,64 @@ export const CLI_CONTRACT: CliContract = { variants: [moveResource('workflows mv', 'workflow')], flags: { folderPath: FOLDER_PATH_FLAG }, }, - importWorkflow: { flags: { folderPath: FOLDER_PATH_FLAG } }, + importWorkflow: { workspaceOperation: true, flags: { folderPath: FOLDER_PATH_FLAG } }, + previewWorkflowImport: { + command: 'workflows import-preview', + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + previewWorkspaceFork: { command: 'workspaces fork-preview', profileWorkspacePath: true }, + forkWorkspace: { + command: 'workspaces fork', + profileWorkspacePath: true, + workspaceOperation: true, + }, + previewWorkspacePush: { command: 'workspaces push-preview', profileWorkspacePath: true }, + pushWorkspace: { + command: 'workspaces push', + profileWorkspacePath: true, + workspaceOperation: true, + confirm: 'This replaces target workflows and may archive targets whose sources were deleted.', + flags: { confirm: { omit: true } }, + }, + previewWorkspacePull: { command: 'workspaces pull-preview', profileWorkspacePath: true }, + pullWorkspace: { + command: 'workspaces pull', + profileWorkspacePath: true, + workspaceOperation: true, + confirm: 'This replaces target workflows and may archive targets whose sources were deleted.', + flags: { confirm: { omit: true } }, + }, + getWorkspaceForkAvailability: { + command: 'workspaces fork-availability', + profileWorkspacePath: true, + }, + getWorkspaceForkLineage: { command: 'workspaces lineage', profileWorkspacePath: true }, + listWorkspaceForkChildren: { command: 'workspaces children', profileWorkspacePath: true }, + listWorkspaceForkResources: { command: 'workspaces fork-resources', profileWorkspacePath: true }, + getWorkspaceForkMappings: { command: 'workspaces mappings get', profileWorkspacePath: true }, + updateWorkspaceForkMappings: { + command: 'workspaces mappings update', + profileWorkspacePath: true, + }, + rollbackWorkspaceFork: { + command: 'workspaces fork-rollback', + profileWorkspacePath: true, + confirm: 'This restores the latest sync using its prior deployed versions.', + }, + unlinkWorkspaceFork: { + command: 'workspaces unlink', + profileWorkspacePath: true, + confirm: 'This removes the fork relationship and its persisted mappings.', + }, + updateWorkspaceForkExclusions: { + command: 'workspaces sync-exclusions', + profileWorkspacePath: true, + flags: { workflowIds: { name: 'workflow', list: true } }, + }, + getWorkspaceOperation: { command: 'workspaces operations get', profileWorkspacePath: true }, + listWorkspaceOperations: { command: 'workspaces operations list', profileWorkspacePath: true }, + listSelector: { command: 'selectors list' }, + getSelector: { command: 'selectors get' }, createCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, updateCustomTool: { flags: { schema: { json: true, describe: CUSTOM_TOOL_SCHEMA_HELP } } }, // A dependency set is typed one specifier at a time or pasted from a @@ -1473,6 +1530,12 @@ export const CLI_CONTRACT: CliContract = { // produce something `sim workflows import` accepts back. exportWorkflow: { describe: 'Print a workflow as a portable JSON document', + flags: { + includeReferences: { + boolean: true, + describe: 'Include non-secret resource identities for mapped import', + }, + }, document: true, }, diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index d766c6a8a5a..db93541445f 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -311,6 +311,8 @@ export interface CommandSpec { * when the profile says so) whatever the profile's display format is. */ document?: boolean + /** Mutation returns a durable workspace operation and supports --wait. */ + workspaceOperation?: boolean /** Keep the operation out of the CLI surface entirely. */ hidden?: boolean } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 51a4feeb3c0..6e860676639 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3673,7 +3673,9 @@ export type ExportWorkflowParams = { workflowId: string } -export type ExportWorkflowQuery = Record +export type ExportWorkflowQuery = { + includeReferences?: boolean +} type ExportWorkflowResponseRef0 = { version: '1.0' @@ -3686,12 +3688,108 @@ type ExportWorkflowResponseRef0 = { folderPath: string } state: Record + referenceManifest?: { + version: 1 + references: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + sourceId: string + required: boolean + occurrences: Array<{ + blockId: string + subBlockKey: string + valuePath: Array + positions?: Array + encoding: 'scalar' | 'array' | 'csv' | 'files' | 'environment' + }> + }> + } } export type ExportWorkflowResponse = { data: ExportWorkflowResponseRef0 } +/** `POST /api/v2/workspaces/[workspaceId]/fork` */ +export type ForkWorkspaceParams = { + workspaceId: string +} + +export type ForkWorkspaceQuery = Record + +export type ForkWorkspaceBody = { + name?: string + copy?: { + files?: Array + tables?: Array + knowledgeBases?: Array + customTools?: Array + skills?: Array + mcpServers?: Array + workflowMcpServers?: Array + } + requestId: string + previewFingerprint: string +} + +type ForkWorkspaceResponseRef0 = { + operationId: string + requestId: string + workspaceId: string + kind: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied: true + status: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds: Array + issues: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } +} + +export type ForkWorkspaceResponse = { + data: ForkWorkspaceResponseRef0 +} + /** `GET /api/v2/audit-logs/[auditLogId]` */ export type GetAuditLogParams = { auditLogId: string @@ -4564,6 +4662,117 @@ export type GetSandboxResponse = { data: GetSandboxResponseRef0 } +/** `POST /api/v2/selectors/get` */ +export type GetSelectorQuery = Record + +export type GetSelectorBody = { + workspaceId: string + selectorKey: + | 'airtable.bases' + | 'airtable.tables' + | 'asana.workspaces' + | 'attio.lists' + | 'attio.objects' + | 'bigquery.datasets' + | 'bigquery.tables' + | 'bitbucket.workspaces' + | 'bitbucket.repositories' + | 'calcom.eventTypes' + | 'calcom.schedules' + | 'clickup.workspaces' + | 'clickup.spaces' + | 'clickup.folders' + | 'clickup.lists' + | 'confluence.spaces' + | 'confluence.spacesById' + | 'confluence.pages' + | 'google.tasks.lists' + | 'gmail.labels' + | 'google.calendar' + | 'google.drive' + | 'google.sheets' + | 'harmonic.savedSearches' + | 'hubspot.lists' + | 'hubspot.owners' + | 'hubspot.pipelines' + | 'hubspot.pipelineStages' + | 'hubspot.properties' + | 'jsm.requestTypes' + | 'jsm.serviceDesks' + | 'microsoft.planner.plans' + | 'notion.databases' + | 'notion.pages' + | 'netsuite.recordTypes' + | 'netsuite.asyncTasks' + | 'pipedrive.pipelines' + | 'sharepoint.lists' + | 'trello.boards' + | 'zoho_desk.organizations' + | 'zoho_desk.departments' + | 'zoho_desk.agents' + | 'zoom.meetings' + | 'slack.channels' + | 'snowflake.databases' + | 'snowflake.schemas' + | 'snowflake.tables' + | 'snowflake.warehouses' + | 'snowflake.roles' + | 'snowflake.fileFormats' + | 'snowflake.procedures' + | 'slack.users' + | 'outlook.folders' + | 'outlook.calendars' + | 'microsoft.teams' + | 'microsoft.chats' + | 'microsoft.channels' + | 'microsoft.planner' + | 'onedrive.files' + | 'onedrive.folders' + | 'sharepoint.sites' + | 'microsoft.excel' + | 'microsoft.excel.drives' + | 'microsoft.excel.sheets' + | 'microsoft.word' + | 'wealthbox.contacts' + | 'jira.issues' + | 'jira.projects' + | 'linear.projects' + | 'linear.teams' + | 'monday.boards' + | 'monday.groups' + | 'webflow.sites' + | 'webflow.collections' + | 'webflow.items' + | 'cloudwatch.logGroups' + | 'cloudwatch.logStreams' + | 'imap.mailboxes' + | 'mcp.tools' + | 'managedAgent.agents' + | 'managedAgent.environments' + | 'managedAgent.vaults' + | 'managedAgent.memoryStores' + | 'knowledge.documents' + | 'sim.workflows' + | 'table.columns' + | 'table.outputColumns' + | 'workspace.secretNames' + | 'workspace.sandboxes' + | 'providers.ollamaEmbeddingModels' + | 'providers.openrouterEmbeddingModels' + context?: Record + id: string +} + +type GetSelectorResponseRef0 = { + id: string + label: string + meta?: Record +} + +export type GetSelectorResponse = { + data: GetSelectorResponseRef0 | null +} + /** `GET /api/v2/skills/[skillId]` */ export type GetSkillParams = { skillId: string @@ -5274,6 +5483,137 @@ export type GetWorkspaceResponse = { data: GetWorkspaceResponseRef0 } +/** `GET /api/v2/workspaces/[workspaceId]/fork/availability` */ +export type GetWorkspaceForkAvailabilityParams = { + workspaceId: string +} + +export type GetWorkspaceForkAvailabilityQuery = Record + +type GetWorkspaceForkAvailabilityResponseRef0 = { + available: boolean +} + +export type GetWorkspaceForkAvailabilityResponse = { + data: GetWorkspaceForkAvailabilityResponseRef0 +} + +/** `GET /api/v2/workspaces/[workspaceId]/fork/lineage` */ +export type GetWorkspaceForkLineageParams = { + workspaceId: string +} + +export type GetWorkspaceForkLineageQuery = Record + +type GetWorkspaceForkLineageResponseRef0 = { + current: { + id: string + name: string + organizationId: string | null + } + parent: { + id: string + name: string + organizationId: string | null + } | null +} + +export type GetWorkspaceForkLineageResponse = { + data: GetWorkspaceForkLineageResponseRef0 +} + +/** `GET /api/v2/workspaces/[workspaceId]/fork/mappings` */ +export type GetWorkspaceForkMappingsParams = { + workspaceId: string +} + +export type GetWorkspaceForkMappingsQuery = { + otherWorkspaceId: string + direction: 'push' | 'pull' + limit?: number + cursor?: string + sortBy?: 'id' + sortOrder?: 'asc' +} + +type GetWorkspaceForkMappingsResponseRef0 = { + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + id: string +} + +export type GetWorkspaceForkMappingsResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/operations/[operationId]` */ +export type GetWorkspaceOperationParams = { + workspaceId: string + operationId: string +} + +export type GetWorkspaceOperationQuery = Record + +type GetWorkspaceOperationResponseRef0 = { + operationId: string + requestId: string + workspaceId: string + kind: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied: true + status: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds: Array + issues: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } +} + +export type GetWorkspaceOperationResponse = { + data: GetWorkspaceOperationResponseRef0 +} + /** `POST /api/v2/skills/[skillId]/editors` */ export type GrantSkillEditorParams = { skillId: string @@ -5308,6 +5648,53 @@ export type ImportWorkflowBody = { folderPath?: ImportWorkflowBodyRef0 name?: string description?: string + mappings?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + sourceId: string + targetId: string | null + }> + bindings?: Array<{ + blockId: string + subBlockKey: string + valuePath?: Array + positions?: Array + encoding?: 'scalar' | 'array' | 'csv' | 'files' | 'environment' + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + targetId: string | null + }> + dependentValues?: Array<{ + blockId: string + subBlockKey: string + value: string + }> + requestId?: string + previewFingerprint?: string } type ImportWorkflowResponseRef0 = { @@ -5318,6 +5705,44 @@ type ImportWorkflowResponseRef0 = { folderPath: string createdAt: string updatedAt: string + operationId?: string + requestId?: string + kind?: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied?: true + status?: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds?: Array + issues?: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } } export type ImportWorkflowResponse = { @@ -6287,6 +6712,121 @@ export type ListSecretsResponse = { nextCursor: string | null } +/** `POST /api/v2/selectors/list` */ +export type ListSelectorQuery = Record + +export type ListSelectorBody = { + workspaceId: string + selectorKey: + | 'airtable.bases' + | 'airtable.tables' + | 'asana.workspaces' + | 'attio.lists' + | 'attio.objects' + | 'bigquery.datasets' + | 'bigquery.tables' + | 'bitbucket.workspaces' + | 'bitbucket.repositories' + | 'calcom.eventTypes' + | 'calcom.schedules' + | 'clickup.workspaces' + | 'clickup.spaces' + | 'clickup.folders' + | 'clickup.lists' + | 'confluence.spaces' + | 'confluence.spacesById' + | 'confluence.pages' + | 'google.tasks.lists' + | 'gmail.labels' + | 'google.calendar' + | 'google.drive' + | 'google.sheets' + | 'harmonic.savedSearches' + | 'hubspot.lists' + | 'hubspot.owners' + | 'hubspot.pipelines' + | 'hubspot.pipelineStages' + | 'hubspot.properties' + | 'jsm.requestTypes' + | 'jsm.serviceDesks' + | 'microsoft.planner.plans' + | 'notion.databases' + | 'notion.pages' + | 'netsuite.recordTypes' + | 'netsuite.asyncTasks' + | 'pipedrive.pipelines' + | 'sharepoint.lists' + | 'trello.boards' + | 'zoho_desk.organizations' + | 'zoho_desk.departments' + | 'zoho_desk.agents' + | 'zoom.meetings' + | 'slack.channels' + | 'snowflake.databases' + | 'snowflake.schemas' + | 'snowflake.tables' + | 'snowflake.warehouses' + | 'snowflake.roles' + | 'snowflake.fileFormats' + | 'snowflake.procedures' + | 'slack.users' + | 'outlook.folders' + | 'outlook.calendars' + | 'microsoft.teams' + | 'microsoft.chats' + | 'microsoft.channels' + | 'microsoft.planner' + | 'onedrive.files' + | 'onedrive.folders' + | 'sharepoint.sites' + | 'microsoft.excel' + | 'microsoft.excel.drives' + | 'microsoft.excel.sheets' + | 'microsoft.word' + | 'wealthbox.contacts' + | 'jira.issues' + | 'jira.projects' + | 'linear.projects' + | 'linear.teams' + | 'monday.boards' + | 'monday.groups' + | 'webflow.sites' + | 'webflow.collections' + | 'webflow.items' + | 'cloudwatch.logGroups' + | 'cloudwatch.logStreams' + | 'imap.mailboxes' + | 'mcp.tools' + | 'managedAgent.agents' + | 'managedAgent.environments' + | 'managedAgent.vaults' + | 'managedAgent.memoryStores' + | 'knowledge.documents' + | 'sim.workflows' + | 'table.columns' + | 'table.outputColumns' + | 'workspace.secretNames' + | 'workspace.sandboxes' + | 'providers.ollamaEmbeddingModels' + | 'providers.openrouterEmbeddingModels' + context?: Record + search?: string + cursor?: string + limit?: number +} + +type ListSelectorResponseRef0 = { + id: string + label: string + meta?: Record +} + +export type ListSelectorResponse = { + data: Array + nextCursor: string | null + truncated: boolean +} + /** `GET /api/v2/skills/[skillId]/editors` */ export type ListSkillEditorsParams = { skillId: string @@ -6772,6 +7312,62 @@ export type ListWorkflowVersionsResponse = { nextCursor: string | null } +/** `GET /api/v2/workspaces/[workspaceId]/fork/children` */ +export type ListWorkspaceForkChildrenParams = { + workspaceId: string +} + +export type ListWorkspaceForkChildrenQuery = { + limit?: number + cursor?: string + sortBy?: 'createdAt' + sortOrder?: 'desc' +} + +type ListWorkspaceForkChildrenResponseRef0 = { + id: string + name: string + organizationId: string | null + createdAt: string +} + +export type ListWorkspaceForkChildrenResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/workspaces/[workspaceId]/fork/resources` */ +export type ListWorkspaceForkResourcesParams = { + workspaceId: string +} + +export type ListWorkspaceForkResourcesQuery = { + limit?: number + cursor?: string + kind: + | 'files' + | 'tables' + | 'knowledgeBases' + | 'customTools' + | 'skills' + | 'mcpServers' + | 'workflowMcpServers' + sortBy?: 'id' + sortOrder?: 'asc' +} + +type ListWorkspaceForkResourcesResponseRef0 = { + id: string + label: string + folderId?: string | null + folderName?: string | null +} + +export type ListWorkspaceForkResourcesResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/workspaces/[workspaceId]/members` */ export type ListWorkspaceMembersParams = { workspaceId: string @@ -6796,6 +7392,64 @@ export type ListWorkspaceMembersResponse = { nextCursor: string | null } +/** `GET /api/v2/workspaces/[workspaceId]/operations` */ +export type ListWorkspaceOperationsParams = { + workspaceId: string +} + +export type ListWorkspaceOperationsQuery = { + limit?: number + cursor?: string + requestId?: string +} + +type ListWorkspaceOperationsResponseRef0 = { + operationId: string + requestId: string + workspaceId: string + kind: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied: true + status: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds: Array + issues: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } +} + +export type ListWorkspaceOperationsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/workspaces` */ export type ListWorkspacesQuery = { sortBy?: 'name' | 'createdAt' | 'updatedAt' @@ -6900,78 +7554,743 @@ export type MoveWorkflowsResponse = { data: MoveWorkflowsResponseRef0 } -/** `POST /api/v2/tables/[tableId]/query` */ -export type QueryRowsParams = { - tableId: string -} +/** `POST /api/v2/workflows/import/preview` */ +export type PreviewWorkflowImportQuery = Record -export type QueryRowsQuery = Record +type PreviewWorkflowImportBodyRef0 = string -type QueryRowsBodyRef0 = - | { - all: Array< - | QueryRowsBodyRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > +export type PreviewWorkflowImportBody = { + workspaceId: string + workflow: string | Record + folderPath?: PreviewWorkflowImportBodyRef0 + name?: string + description?: string + mappings?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + sourceId: string + targetId: string | null + }> + bindings?: Array<{ + blockId: string + subBlockKey: string + valuePath?: Array + positions?: Array + encoding?: 'scalar' | 'array' | 'csv' | 'files' | 'environment' + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + targetId: string | null + }> + dependentValues?: Array<{ + blockId: string + subBlockKey: string + value: string + }> +} + +type PreviewWorkflowImportResponseRef0 = { + previewFingerprint: string + ready: boolean + bindings: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + sourceId: string + targetId: string | null + required: boolean + occurrence: { + blockId: string + subBlockKey: string + valuePath: Array + positions?: Array + encoding: 'scalar' | 'array' | 'csv' | 'files' | 'environment' } - | { - any: Array< - | QueryRowsBodyRef0 - | { - field: string - op: - | 'eq' - | 'ne' - | 'gt' - | 'gte' - | 'lt' - | 'lte' - | 'in' - | 'nin' - | 'contains' - | 'ncontains' - | 'startsWith' - | 'endsWith' - | 'like' - | 'ilike' - | 'nlike' - | 'nilike' - | 'isEmpty' - | 'isNotEmpty' - | 'isNull' - | 'isNotNull' - value?: unknown - } - > + }> + unresolvedBindings: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + | 'workflow' + sourceId: string + targetId: string | null + required: boolean + occurrence: { + blockId: string + subBlockKey: string + valuePath: Array + positions?: Array + encoding: 'scalar' | 'array' | 'csv' | 'files' | 'environment' } - | { - field: string - op: - | 'eq' + }> + configuration: Array<{ + blockId: string + subBlockKey: string + title: string + required: boolean + configured: boolean + multiSelect?: boolean + selectorKey?: string + context: Record + requiresAuthentication: boolean + }> + unresolvedConfiguration: Array<{ + blockId: string + subBlockKey: string + title: string + required: boolean + configured: boolean + multiSelect?: boolean + selectorKey?: string + context: Record + requiresAuthentication: boolean + }> + discovery: Array<{ + kind: string + command: string + humanAuthorizationMayBeRequired: boolean + }> +} + +export type PreviewWorkflowImportResponse = { + data: PreviewWorkflowImportResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/fork/preview` */ +export type PreviewWorkspaceForkParams = { + workspaceId: string +} + +export type PreviewWorkspaceForkQuery = Record + +export type PreviewWorkspaceForkBody = { + name?: string + copy?: { + files?: Array + tables?: Array + knowledgeBases?: Array + customTools?: Array + skills?: Array + mcpServers?: Array + workflowMcpServers?: Array + } +} + +type PreviewWorkspaceForkResponseRef0 = { + previewFingerprint: string + sourceWorkspaceId: string + workflows: Array<{ + sourceWorkflowId: string + name: string + }> + selectedResourceCount: number + draftOnly: true +} + +export type PreviewWorkspaceForkResponse = { + data: PreviewWorkspaceForkResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/fork/pull/preview` */ +export type PreviewWorkspacePullParams = { + workspaceId: string +} + +export type PreviewWorkspacePullQuery = Record + +export type PreviewWorkspacePullBody = { + otherWorkspaceId: string + mappings?: Array<{ + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + }> + dependentValues?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + value: string + }> + copyResources?: { + knowledgeBases?: Array + tables?: Array + customTools?: Array + skills?: Array + files?: Array + mcpServers?: Array + } + dropReferences?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + sourceId: string + }> + triggerMappings?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + adoptPath: string | null + }> +} + +type PreviewWorkspacePullResponseRef0 = { + previewFingerprint: string + sourceWorkspaceId: string + targetWorkspaceId: string + ready: boolean + workflows: Array<{ + action: 'create' | 'replace' | 'archive' + sourceWorkflowId?: string + targetWorkflowId?: string + name: string + }> + unresolvedBindings: Array<{ + kind: string + sourceId: string + blockName?: string + reason?: string + }> + configuration: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + title: string + required: boolean + currentValue: string + multiSelect?: boolean + selectorKey?: string + discoveryWorkspaceId: string + context: Record + parentKind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + parentSourceId: string + parentContextKey?: string + }> + excludedTargets: Array<{ + id: string + name: string + }> + triggerSlots: Array<{ + sourceWorkflowId: string + sourceBlockId: string + blockName: string + workflowName: string + ownPath: string | null + adoptablePaths: Array + defaultAdoptPath: string | null + }> + triggerUrlChanges: Array<{ + workflowName: string + path: string + }> +} + +export type PreviewWorkspacePullResponse = { + data: PreviewWorkspacePullResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/fork/push/preview` */ +export type PreviewWorkspacePushParams = { + workspaceId: string +} + +export type PreviewWorkspacePushQuery = Record + +export type PreviewWorkspacePushBody = { + otherWorkspaceId: string + mappings?: Array<{ + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + }> + dependentValues?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + value: string + }> + copyResources?: { + knowledgeBases?: Array + tables?: Array + customTools?: Array + skills?: Array + files?: Array + mcpServers?: Array + } + dropReferences?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + sourceId: string + }> + triggerMappings?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + adoptPath: string | null + }> +} + +type PreviewWorkspacePushResponseRef0 = { + previewFingerprint: string + sourceWorkspaceId: string + targetWorkspaceId: string + ready: boolean + workflows: Array<{ + action: 'create' | 'replace' | 'archive' + sourceWorkflowId?: string + targetWorkflowId?: string + name: string + }> + unresolvedBindings: Array<{ + kind: string + sourceId: string + blockName?: string + reason?: string + }> + configuration: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + title: string + required: boolean + currentValue: string + multiSelect?: boolean + selectorKey?: string + discoveryWorkspaceId: string + context: Record + parentKind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + parentSourceId: string + parentContextKey?: string + }> + excludedTargets: Array<{ + id: string + name: string + }> + triggerSlots: Array<{ + sourceWorkflowId: string + sourceBlockId: string + blockName: string + workflowName: string + ownPath: string | null + adoptablePaths: Array + defaultAdoptPath: string | null + }> + triggerUrlChanges: Array<{ + workflowName: string + path: string + }> +} + +export type PreviewWorkspacePushResponse = { + data: PreviewWorkspacePushResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/fork/pull` */ +export type PullWorkspaceParams = { + workspaceId: string +} + +export type PullWorkspaceQuery = Record + +export type PullWorkspaceBody = { + otherWorkspaceId: string + mappings?: Array<{ + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + }> + dependentValues?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + value: string + }> + copyResources?: { + knowledgeBases?: Array + tables?: Array + customTools?: Array + skills?: Array + files?: Array + mcpServers?: Array + } + dropReferences?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + sourceId: string + }> + triggerMappings?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + adoptPath: string | null + }> + requestId: string + previewFingerprint: string + confirm: true +} + +type PullWorkspaceResponseRef0 = { + operationId: string + requestId: string + workspaceId: string + kind: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied: true + status: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds: Array + issues: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } +} + +export type PullWorkspaceResponse = { + data: PullWorkspaceResponseRef0 +} + +/** `POST /api/v2/workspaces/[workspaceId]/fork/push` */ +export type PushWorkspaceParams = { + workspaceId: string +} + +export type PushWorkspaceQuery = Record + +export type PushWorkspaceBody = { + otherWorkspaceId: string + mappings?: Array<{ + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + }> + dependentValues?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + subBlockKey: string + value: string + }> + copyResources?: { + knowledgeBases?: Array + tables?: Array + customTools?: Array + skills?: Array + files?: Array + mcpServers?: Array + } + dropReferences?: Array<{ + kind: + | 'credential' + | 'env-var' + | 'knowledge-base' + | 'knowledge-document' + | 'table' + | 'file' + | 'file-folder' + | 'mcp-server' + | 'custom-tool' + | 'custom-block' + | 'skill' + | 'sandbox' + sourceId: string + }> + triggerMappings?: Array<{ + sourceWorkflowId: string + sourceBlockId: string + adoptPath: string | null + }> + requestId: string + previewFingerprint: string + confirm: true +} + +type PushWorkspaceResponseRef0 = { + operationId: string + requestId: string + workspaceId: string + kind: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' + applied: true + status: + | 'processing' + | 'completed' + | 'completed_with_warnings' + | 'requires_configuration' + | 'failed' + resourceIds: Array + issues: Array<{ + code: string + message: string + workflowId?: string + blockId?: string + subBlockKey?: string + }> + idMap?: Record + deploymentOperationIds?: Array + deployments?: Array<{ + operationId: string + workflowId: string + version: number + status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded' + ready: boolean + pendingComponents: Array + }> + triggerUrlChanges?: Array<{ + workflowName: string + path: string + }> + backgroundWorkId?: string + copyProgress?: { + status: 'pending' | 'completed' | 'failed' + copied: number + failed: number + } +} + +export type PushWorkspaceResponse = { + data: PushWorkspaceResponseRef0 +} + +/** `POST /api/v2/tables/[tableId]/query` */ +export type QueryRowsParams = { + tableId: string +} + +export type QueryRowsQuery = Record + +type QueryRowsBodyRef0 = + | { + all: Array< + | QueryRowsBodyRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + any: Array< + | QueryRowsBodyRef0 + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } + > + } + | { + field: string + op: + | 'eq' | 'ne' | 'gt' | 'gte' @@ -7906,6 +9225,29 @@ export type RollbackWorkflowResponse = { data: RollbackWorkflowResponseRef4 } +/** `POST /api/v2/workspaces/[workspaceId]/fork/rollback` */ +export type RollbackWorkspaceForkParams = { + workspaceId: string +} + +export type RollbackWorkspaceForkQuery = Record + +export type RollbackWorkspaceForkBody = { + otherWorkspaceId: string +} + +type RollbackWorkspaceForkResponseRef0 = { + restored: number + archived: number + unarchived: number + skipped: number + pendingActivations: Array +} + +export type RollbackWorkspaceForkResponse = { + data: RollbackWorkspaceForkResponseRef0 +} + /** `POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ export type RunRowEnrichmentParams = { tableId: string @@ -8265,6 +9607,25 @@ export type UndeployWorkflowMcpToolResponse = { data: UndeployWorkflowMcpToolResponseRef0 } +/** `POST /api/v2/workspaces/[workspaceId]/fork/unlink` */ +export type UnlinkWorkspaceForkParams = { + workspaceId: string +} + +export type UnlinkWorkspaceForkQuery = Record + +export type UnlinkWorkspaceForkBody = { + otherWorkspaceId: string +} + +type UnlinkWorkspaceForkResponseRef0 = { + unlinked: boolean +} + +export type UnlinkWorkspaceForkResponse = { + data: UnlinkWorkspaceForkResponseRef0 +} + /** `POST /api/v2/files/[fileId]/unzip` */ export type UnzipFileParams = { fileId: string @@ -9397,6 +10758,63 @@ export type UpdateWorkflowVersionResponse = { data: UpdateWorkflowVersionResponseRef0 } +/** `PUT /api/v2/workspaces/[workspaceId]/fork/exclusions` */ +export type UpdateWorkspaceForkExclusionsParams = { + workspaceId: string +} + +export type UpdateWorkspaceForkExclusionsQuery = Record + +export type UpdateWorkspaceForkExclusionsBody = { + workflowIds: Array + forkSyncExcluded: boolean +} + +type UpdateWorkspaceForkExclusionsResponseRef0 = { + updated: number +} + +export type UpdateWorkspaceForkExclusionsResponse = { + data: UpdateWorkspaceForkExclusionsResponseRef0 +} + +/** `PUT /api/v2/workspaces/[workspaceId]/fork/mappings` */ +export type UpdateWorkspaceForkMappingsParams = { + workspaceId: string +} + +export type UpdateWorkspaceForkMappingsQuery = Record + +export type UpdateWorkspaceForkMappingsBody = { + otherWorkspaceId: string + direction: 'push' | 'pull' + mappings: Array<{ + resourceType: + | 'oauth_credential' + | 'service_account_credential' + | 'env_var' + | 'table' + | 'knowledge_base' + | 'file' + | 'file_folder' + | 'mcp_server' + | 'custom_block' + | 'custom_tool' + | 'skill' + | 'sandbox' + sourceId: string + targetId: string | null + }> +} + +type UpdateWorkspaceForkMappingsResponseRef0 = { + updated: number +} + +export type UpdateWorkspaceForkMappingsResponse = { + data: UpdateWorkspaceForkMappingsResponseRef0 +} + /** `POST /api/v2/knowledge/[knowledgeBaseId]/documents` */ export type UploadKnowledgeDocumentParams = { knowledgeBaseId: string @@ -11469,9 +12887,43 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Export Workflow', - }, - getAuditLog: { - method: 'GET', + query: { + includeReferences: { + kind: 'boolean', + describe: + 'Include non-secret resource identifiers and source field occurrences for mapped imports.', + }, + }, + }, + forkWorkspace: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Fork Workspace', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'Display name of the workflow or workspace.' }, + copy: { + kind: 'object', + describe: + 'Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.', + }, + requestId: { + kind: 'string', + required: true, + describe: 'Stable client request ID for reconciliation and identical retries.', + }, + previewFingerprint: { + kind: 'string', + required: true, + describe: 'Fingerprint of the reviewed preview and its choices.', + }, + }, + }, + getAuditLog: { + method: 'GET', path: '/api/v2/audit-logs/[auditLogId]', pathParams: ['auditLogId'] as const, pathParamDocs: { auditLogId: 'Audit-log entry identifier.' }, @@ -11786,6 +13238,126 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the sandbox.' }, }, }, + getSelector: { + method: 'POST', + path: '/api/v2/selectors/get', + pathParams: [] as const, + responseMode: 'json', + summary: 'Get Selector Option', + workspaceKeyUnsupported: true, + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Explicit current workspace scope.', + }, + selectorKey: { + kind: 'enum', + required: true, + values: [ + 'airtable.bases', + 'airtable.tables', + 'asana.workspaces', + 'attio.lists', + 'attio.objects', + 'bigquery.datasets', + 'bigquery.tables', + 'bitbucket.workspaces', + 'bitbucket.repositories', + 'calcom.eventTypes', + 'calcom.schedules', + 'clickup.workspaces', + 'clickup.spaces', + 'clickup.folders', + 'clickup.lists', + 'confluence.spaces', + 'confluence.spacesById', + 'confluence.pages', + 'google.tasks.lists', + 'gmail.labels', + 'google.calendar', + 'google.drive', + 'google.sheets', + 'harmonic.savedSearches', + 'hubspot.lists', + 'hubspot.owners', + 'hubspot.pipelines', + 'hubspot.pipelineStages', + 'hubspot.properties', + 'jsm.requestTypes', + 'jsm.serviceDesks', + 'microsoft.planner.plans', + 'notion.databases', + 'notion.pages', + 'netsuite.recordTypes', + 'netsuite.asyncTasks', + 'pipedrive.pipelines', + 'sharepoint.lists', + 'trello.boards', + 'zoho_desk.organizations', + 'zoho_desk.departments', + 'zoho_desk.agents', + 'zoom.meetings', + 'slack.channels', + 'snowflake.databases', + 'snowflake.schemas', + 'snowflake.tables', + 'snowflake.warehouses', + 'snowflake.roles', + 'snowflake.fileFormats', + 'snowflake.procedures', + 'slack.users', + 'outlook.folders', + 'outlook.calendars', + 'microsoft.teams', + 'microsoft.chats', + 'microsoft.channels', + 'microsoft.planner', + 'onedrive.files', + 'onedrive.folders', + 'sharepoint.sites', + 'microsoft.excel', + 'microsoft.excel.drives', + 'microsoft.excel.sheets', + 'microsoft.word', + 'wealthbox.contacts', + 'jira.issues', + 'jira.projects', + 'linear.projects', + 'linear.teams', + 'monday.boards', + 'monday.groups', + 'webflow.sites', + 'webflow.collections', + 'webflow.items', + 'cloudwatch.logGroups', + 'cloudwatch.logStreams', + 'imap.mailboxes', + 'mcp.tools', + 'managedAgent.agents', + 'managedAgent.environments', + 'managedAgent.vaults', + 'managedAgent.memoryStores', + 'knowledge.documents', + 'sim.workflows', + 'table.columns', + 'table.outputColumns', + 'workspace.secretNames', + 'workspace.sandboxes', + 'providers.ollamaEmbeddingModels', + 'providers.openrouterEmbeddingModels', + ] as const, + describe: 'Registered selector key for discovering this field’s destination options.', + }, + context: { + kind: 'object', + default: {}, + describe: + 'Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.', + }, + id: { kind: 'string', required: true, describe: 'Resource identifier.' }, + }, + }, getSkill: { method: 'GET', path: '/api/v2/skills/[skillId]', @@ -12006,6 +13578,81 @@ export const V2_OPERATIONS = { responseMode: 'json', summary: 'Get Workspace', }, + getWorkspaceForkAvailability: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/availability', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Get Workspace Fork Availability', + workspaceKeyUnsupported: true, + }, + getWorkspaceForkLineage: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/lineage', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Get Workspace Fork Lineage', + workspaceKeyUnsupported: true, + }, + getWorkspaceForkMappings: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/mappings', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Get Workspace Fork Mappings', + workspaceKeyUnsupported: true, + query: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + direction: { + kind: 'enum', + required: true, + values: ['push', 'pull'] as const, + describe: + 'Push means current to other; pull means other to current, independent of parent/child orientation.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['id'] as const, + default: 'id', + describe: 'Supported stable sort key for this collection.', + }, + sortOrder: { + kind: 'enum', + values: ['asc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + }, + }, + getWorkspaceOperation: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/operations/[operationId]', + pathParams: ['workspaceId', 'operationId'] as const, + pathParamDocs: { + workspaceId: 'Explicit current workspace scope.', + operationId: 'Durable operation identifier to use for polling.', + }, + responseMode: 'json', + summary: 'Get Workspace Operation', + }, grantSkillEditor: { method: 'POST', path: '/api/v2/skills/[skillId]/editors', @@ -12050,6 +13697,27 @@ export const V2_OPERATIONS = { }, name: { kind: 'string', describe: 'Override for the imported workflow name.' }, description: { kind: 'string', describe: 'Override for the imported workflow description.' }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + bindings: { + kind: 'array', + describe: 'Resolved and unresolved source occurrences with their destination selections.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + requestId: { + kind: 'string', + describe: 'Stable client request ID for reconciliation and identical retries.', + }, + previewFingerprint: { + kind: 'string', + describe: 'Fingerprint of the reviewed preview and its choices.', + }, }, }, listAuditLogs: { @@ -13103,6 +14771,135 @@ export const V2_OPERATIONS = { }, }, }, + listSelector: { + method: 'POST', + path: '/api/v2/selectors/list', + pathParams: [] as const, + responseMode: 'json', + summary: 'List Selector Options', + workspaceKeyUnsupported: true, + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Explicit current workspace scope.', + }, + selectorKey: { + kind: 'enum', + required: true, + values: [ + 'airtable.bases', + 'airtable.tables', + 'asana.workspaces', + 'attio.lists', + 'attio.objects', + 'bigquery.datasets', + 'bigquery.tables', + 'bitbucket.workspaces', + 'bitbucket.repositories', + 'calcom.eventTypes', + 'calcom.schedules', + 'clickup.workspaces', + 'clickup.spaces', + 'clickup.folders', + 'clickup.lists', + 'confluence.spaces', + 'confluence.spacesById', + 'confluence.pages', + 'google.tasks.lists', + 'gmail.labels', + 'google.calendar', + 'google.drive', + 'google.sheets', + 'harmonic.savedSearches', + 'hubspot.lists', + 'hubspot.owners', + 'hubspot.pipelines', + 'hubspot.pipelineStages', + 'hubspot.properties', + 'jsm.requestTypes', + 'jsm.serviceDesks', + 'microsoft.planner.plans', + 'notion.databases', + 'notion.pages', + 'netsuite.recordTypes', + 'netsuite.asyncTasks', + 'pipedrive.pipelines', + 'sharepoint.lists', + 'trello.boards', + 'zoho_desk.organizations', + 'zoho_desk.departments', + 'zoho_desk.agents', + 'zoom.meetings', + 'slack.channels', + 'snowflake.databases', + 'snowflake.schemas', + 'snowflake.tables', + 'snowflake.warehouses', + 'snowflake.roles', + 'snowflake.fileFormats', + 'snowflake.procedures', + 'slack.users', + 'outlook.folders', + 'outlook.calendars', + 'microsoft.teams', + 'microsoft.chats', + 'microsoft.channels', + 'microsoft.planner', + 'onedrive.files', + 'onedrive.folders', + 'sharepoint.sites', + 'microsoft.excel', + 'microsoft.excel.drives', + 'microsoft.excel.sheets', + 'microsoft.word', + 'wealthbox.contacts', + 'jira.issues', + 'jira.projects', + 'linear.projects', + 'linear.teams', + 'monday.boards', + 'monday.groups', + 'webflow.sites', + 'webflow.collections', + 'webflow.items', + 'cloudwatch.logGroups', + 'cloudwatch.logStreams', + 'imap.mailboxes', + 'mcp.tools', + 'managedAgent.agents', + 'managedAgent.environments', + 'managedAgent.vaults', + 'managedAgent.memoryStores', + 'knowledge.documents', + 'sim.workflows', + 'table.columns', + 'table.outputColumns', + 'workspace.secretNames', + 'workspace.sandboxes', + 'providers.ollamaEmbeddingModels', + 'providers.openrouterEmbeddingModels', + ] as const, + describe: 'Registered selector key for discovering this field’s destination options.', + }, + context: { + kind: 'object', + default: {}, + describe: + 'Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.', + }, + search: { kind: 'string', describe: 'Provider option search text.' }, + cursor: { + kind: 'string', + describe: 'Opaque continuation cursor returned by the preceding page.', + }, + limit: { + kind: 'integer', + default: 50, + describe: 'Maximum number of items to return on one page.', + }, + }, + }, listSkillEditors: { method: 'GET', path: '/api/v2/skills/[skillId]/editors', @@ -13588,6 +15385,88 @@ export const V2_OPERATIONS = { }, }, }, + listWorkspaceForkChildren: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/children', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'List Workspace Fork Children', + workspaceKeyUnsupported: true, + query: { + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + sortBy: { + kind: 'enum', + values: ['createdAt'] as const, + default: 'createdAt', + describe: 'Supported stable sort key for this collection.', + }, + sortOrder: { + kind: 'enum', + values: ['desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + }, + }, + listWorkspaceForkResources: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/fork/resources', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'List Workspace Fork Resources', + workspaceKeyUnsupported: true, + query: { + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + kind: { + kind: 'enum', + required: true, + values: [ + 'files', + 'tables', + 'knowledgeBases', + 'customTools', + 'skills', + 'mcpServers', + 'workflowMcpServers', + ] as const, + describe: 'Resource or operation kind.', + }, + sortBy: { + kind: 'enum', + values: ['id'] as const, + default: 'id', + describe: 'Supported stable sort key for this collection.', + }, + sortOrder: { + kind: 'enum', + values: ['asc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + }, + }, listWorkspaceMembers: { method: 'GET', path: '/api/v2/workspaces/[workspaceId]/members', @@ -13609,6 +15488,31 @@ export const V2_OPERATIONS = { }, }, }, + listWorkspaceOperations: { + method: 'GET', + path: '/api/v2/workspaces/[workspaceId]/operations', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'List Workspace Operations', + query: { + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + requestId: { + kind: 'string', + describe: 'Stable client request ID for reconciliation and identical retries.', + }, + }, + }, listWorkspaces: { method: 'GET', path: '/api/v2/workspaces', @@ -13701,6 +15605,248 @@ export const V2_OPERATIONS = { }, }, }, + previewWorkflowImport: { + method: 'POST', + path: '/api/v2/workflows/import/preview', + pathParams: [] as const, + responseMode: 'json', + summary: 'Preview Workflow Import', + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace in which to import the workflow.', + }, + workflow: { + kind: 'unknown', + required: true, + describe: + 'Workflow export object, bare workflow state, or JSON string containing either form.', + }, + folderPath: { + kind: 'string', + describe: 'Destination folder path; omit for the workspace root.', + }, + name: { kind: 'string', describe: 'Override for the imported workflow name.' }, + description: { kind: 'string', describe: 'Override for the imported workflow description.' }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + bindings: { + kind: 'array', + describe: 'Resolved and unresolved source occurrences with their destination selections.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + }, + }, + previewWorkspaceFork: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/preview', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Preview Workspace Fork', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'Display name of the workflow or workspace.' }, + copy: { + kind: 'object', + describe: + 'Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.', + }, + }, + }, + previewWorkspacePull: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/pull/preview', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Preview Workspace Pull', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + copyResources: { + kind: 'object', + describe: 'Explicit source resources to copy before syncing the workflows.', + }, + dropReferences: { + kind: 'array', + describe: + 'Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.', + }, + triggerMappings: { + kind: 'array', + describe: + 'Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.', + }, + }, + }, + previewWorkspacePush: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/push/preview', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Preview Workspace Push', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + copyResources: { + kind: 'object', + describe: 'Explicit source resources to copy before syncing the workflows.', + }, + dropReferences: { + kind: 'array', + describe: + 'Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.', + }, + triggerMappings: { + kind: 'array', + describe: + 'Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.', + }, + }, + }, + pullWorkspace: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/pull', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Pull Workspace', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + copyResources: { + kind: 'object', + describe: 'Explicit source resources to copy before syncing the workflows.', + }, + dropReferences: { + kind: 'array', + describe: + 'Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.', + }, + triggerMappings: { + kind: 'array', + describe: + 'Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.', + }, + requestId: { + kind: 'string', + required: true, + describe: 'Stable client request ID for reconciliation and identical retries.', + }, + previewFingerprint: { + kind: 'string', + required: true, + describe: 'Fingerprint of the reviewed preview and its choices.', + }, + confirm: { + kind: 'boolean', + required: true, + describe: 'Explicit acknowledgement that sync replaces target workflows.', + }, + }, + }, + pushWorkspace: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/push', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Push Workspace', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + mappings: { + kind: 'array', + describe: 'Mappings keyed by resource type and source identifier.', + }, + dependentValues: { + kind: 'array', + describe: + 'Destination-dependent choices keyed by source workflow, block, and field identities.', + }, + copyResources: { + kind: 'object', + describe: 'Explicit source resources to copy before syncing the workflows.', + }, + dropReferences: { + kind: 'array', + describe: + 'Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.', + }, + triggerMappings: { + kind: 'array', + describe: + 'Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.', + }, + requestId: { + kind: 'string', + required: true, + describe: 'Stable client request ID for reconciliation and identical retries.', + }, + previewFingerprint: { + kind: 'string', + required: true, + describe: 'Fingerprint of the reviewed preview and its choices.', + }, + confirm: { + kind: 'boolean', + required: true, + describe: 'Explicit acknowledgement that sync replaces target workflows.', + }, + }, + }, queryRows: { method: 'POST', path: '/api/v2/tables/[tableId]/query', @@ -14087,6 +16233,22 @@ export const V2_OPERATIONS = { }, }, }, + rollbackWorkspaceFork: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/rollback', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Rollback Workspace Fork', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + }, + }, runRowEnrichment: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', @@ -14325,6 +16487,22 @@ export const V2_OPERATIONS = { summary: 'Unpublish Workflow MCP Tool', workspaceKeyUnsupported: true, }, + unlinkWorkspaceFork: { + method: 'POST', + path: '/api/v2/workspaces/[workspaceId]/fork/unlink', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Unlink Workspace Fork', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + }, + }, unzipFile: { method: 'POST', path: '/api/v2/files/[fileId]/unzip', @@ -14920,6 +17098,55 @@ export const V2_OPERATIONS = { }, }, }, + updateWorkspaceForkExclusions: { + method: 'PUT', + path: '/api/v2/workspaces/[workspaceId]/fork/exclusions', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Update Workspace Fork Exclusions', + workspaceKeyUnsupported: true, + body: { + workflowIds: { + kind: 'array', + required: true, + describe: 'Workflow identifiers in the current workspace.', + }, + forkSyncExcluded: { + kind: 'boolean', + required: true, + describe: 'Whether the named workflows should be skipped as sync sources and targets.', + }, + }, + }, + updateWorkspaceForkMappings: { + method: 'PUT', + path: '/api/v2/workspaces/[workspaceId]/fork/mappings', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Explicit current workspace scope.' }, + responseMode: 'json', + summary: 'Update Workspace Fork Mappings', + workspaceKeyUnsupported: true, + body: { + otherWorkspaceId: { + kind: 'string', + required: true, + describe: 'Workspace on the other side of the direct fork edge.', + }, + direction: { + kind: 'enum', + required: true, + values: ['push', 'pull'] as const, + describe: + 'Push means current to other; pull means other to current, independent of parent/child orientation.', + }, + mappings: { + kind: 'array', + required: true, + describe: 'Mappings keyed by resource type and source identifier.', + }, + }, + }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[knowledgeBaseId]/documents', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index cce5dade884..bbcd16db43b 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -274,6 +274,23 @@ describe('redirects', () => { }) describe('non-JSON responses', () => { + it.each([200, 409, 503])('normalizes unreadable HTTP %s response bodies', async (status) => { + const response = new Response(null, { status }) + vi.spyOn(response, 'text').mockRejectedValue(new Error('Connection closed during response')) + const fetch = vi.fn().mockResolvedValue(response) + vi.stubGlobal('fetch', fetch) + + await expect( + client().request('/api/v2/workspaces/ws_1/operations/operation-1') + ).rejects.toMatchObject({ + name: 'SimApiError', + status, + code: 'RESPONSE_READ_FAILED', + message: 'Unable to read the response: Connection closed during response', + }) + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('names the URL and the shape instead of dumping a page of HTML', async () => { vi.stubGlobal( 'fetch', @@ -984,6 +1001,10 @@ describe('destructive operations are gated', () => { * default by being named something the old regex did not match. */ const DESTRUCTIVE_NON_DELETE = new Set([ + 'pushWorkspace', + 'pullWorkspace', + 'rollbackWorkspaceFork', + 'unlinkWorkspaceFork', // The same application operation as `rollbackWorkflow`, under a different // transition: both switch which version production serves away from the one // the caller last chose. @@ -1005,6 +1026,15 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'forkWorkspace', + 'getSelector', + 'listSelector', + 'previewWorkflowImport', + 'previewWorkspaceFork', + 'previewWorkspacePull', + 'previewWorkspacePush', + 'updateWorkspaceForkExclusions', + 'updateWorkspaceForkMappings', 'addTableColumn', 'addWorkflowGroup', 'addWorkspaceFilesToKnowledgeBase', diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index 88692524ef2..db155fcc0e5 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -14,7 +14,8 @@ export class SimApiError extends Error { message: string, readonly status: number, readonly code: string | null = null, - readonly details?: unknown + readonly details?: unknown, + readonly exitCode = 1 ) { super(message) this.name = 'SimApiError' @@ -212,6 +213,18 @@ function transportErrorMessage(error: unknown): string { return messages.join(': ') || 'Unknown network error' } +async function readResponseText(response: Response): Promise { + try { + return await response.text() + } catch (error) { + throw new SimApiError( + `Unable to read the response: ${transportErrorMessage(error)}`, + response.status, + 'RESPONSE_READ_FAILED' + ) + } +} + /** * Whether this is the refusal a workspace-scoped key gets from an operation only * a personal key may perform, under either code that expresses it. @@ -556,7 +569,7 @@ export class SimClient { async request(path: string, options: RequestOptions = {}): Promise { const { response, url } = await this.send(path, options) - const raw = await response.text() + const raw = await readResponseText(response) if (!raw) return undefined as T try { @@ -652,7 +665,7 @@ export class SimClient { } if (!response.ok) { - const raw = await response.text() + const raw = await readResponseText(response) const error = toApiError(url, response.status, response.headers.get('content-type'), raw) if (response.status === 401) { error.message = `${error.message} — run: sim login --profile ${this.profile.authProfile}` diff --git a/packages/sim-cli/src/index.ts b/packages/sim-cli/src/index.ts index 251e53ad064..36a983333f8 100644 --- a/packages/sim-cli/src/index.ts +++ b/packages/sim-cli/src/index.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node import chalk from 'chalk' +import { dump } from 'js-yaml' import { ProfileConfigError } from './config/index' +import { clientFrom } from './context' import { formatApiErrorDetails, isRequestTimeout, @@ -17,8 +19,9 @@ import { buildProgram } from './program' * friendly message would make it unreportable. */ async function main() { + const program = buildProgram() try { - await buildProgram().parseAsync(process.argv) + await program.parseAsync(process.argv) } catch (error) { if (error instanceof ProfileConfigError) { console.error(chalk.red(`Error: ${sanitize(error.message)}`)) @@ -33,6 +36,23 @@ async function main() { process.exit(1) } if (error instanceof SimApiError) { + let output = program.opts().output + try { + output = clientFrom(program).profile.output + } catch { + /** Preserve the original error when configuration is invalid. */ + } + if (output === 'json' || output === 'yaml') { + const payload = { + error: { + code: error.code ?? 'CLI_ERROR', + message: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }, + } + process.stderr.write(output === 'json' ? `${JSON.stringify(payload)}\n` : dump(payload)) + process.exit(error.exitCode) + } console.error(chalk.red(`Error: ${sanitize(error.message)}`)) if (error.code) console.error(chalk.dim(` code: ${sanitize(error.code)}`)) if (error.details !== undefined) { @@ -40,7 +60,7 @@ async function main() { console.error(chalk.dim(sanitize(line))) } } - process.exit(1) + process.exit(error.exitCode) } throw error } diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index ad0e715ec3c..bcd2f5de7cb 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -3,8 +3,9 @@ */ import { readFileSync } from 'node:fs' import { Command } from 'commander' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { V2_OPERATIONS } from '../generated/v2-api' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import { type GetWorkspaceOperationResponse, V2_OPERATIONS } from '../generated/v2-api' import { SimApiError } from '../http/client' import { BULK_OUTCOME_CHECKS, executeOperation } from './execute' import type { OperationSpec } from './types' @@ -110,6 +111,142 @@ beforeEach(() => { vi.clearAllMocks() }) +describe('workspace mutation receipt identity', () => { + const receipt: GetWorkspaceOperationResponse['data'] = { + operationId: 'operation-1', + requestId: 'original-request', + workspaceId: 'ws_local', + kind: 'workspace_push', + applied: true, + status: 'completed', + resourceIds: ['workflow-1'], + issues: [], + } + const flags = { + requestId: receipt.requestId, + previewFingerprint: 'a'.repeat(64), + otherWorkspaceId: 'ws_other', + yes: true, + } + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function applyPush(wait: boolean) { + return executeOperation( + 'pushWorkspace', + CLI_CONTRACT.pushWorkspace!, + V2_OPERATIONS.pushWorkspace, + [{ ...flags, wait }, new Command('leaf')] + ) + } + + it.each([ + ['importWorkflow', 'workflow_import'], + ['forkWorkspace', 'workspace_fork'], + ['pushWorkspace', 'workspace_push'], + ['pullWorkspace', 'workspace_pull'], + ] as const)('accepts the matching receipt for %s', async (operation, kind) => { + const expected = { ...receipt, kind } + request.mockResolvedValue({ data: expected }) + + await expect( + executeOperation(operation, CLI_CONTRACT[operation]!, V2_OPERATIONS[operation], [ + { ...flags, workflow: '{"blocks":{},"edges":[]}', wait: true }, + new Command('leaf'), + ]) + ).resolves.toBeUndefined() + + expect(request).toHaveBeenCalledTimes(1) + expect(JSON.parse(vi.mocked(console.log).mock.calls[0][0])).toEqual(expected) + }) + + it.each([ + { field: 'requestId', value: 'another-request', wait: false }, + { field: 'requestId', value: 'another-request', wait: true }, + { field: 'workspaceId', value: 'another-workspace', wait: false }, + { field: 'workspaceId', value: 'another-workspace', wait: true }, + { field: 'kind', value: 'workspace_pull', wait: false }, + { field: 'kind', value: 'workspace_pull', wait: true }, + ])( + 'refuses a mismatched $field with wait=$wait and retains submitted identity', + async ({ field, value, wait }) => { + request.mockResolvedValue({ data: { ...receipt, status: 'processing', [field]: value } }) + + await expect(applyPush(wait)).rejects.toMatchObject({ + code: 'MUTATION_OUTCOME_UNKNOWN', + details: { requestId: 'original-request', workspaceId: 'ws_local', applied: 'unknown' }, + }) + + expect(request).toHaveBeenCalledTimes(1) + expect(console.log).not.toHaveBeenCalled() + } + ) + + it('retains submitted identity when the receipt is malformed', async () => { + request.mockResolvedValue({ data: { applied: true, operationId: 'untrusted-operation' } }) + await expect(applyPush(true)).rejects.toMatchObject({ + code: 'MUTATION_OUTCOME_UNKNOWN', + details: { requestId: 'original-request', workspaceId: 'ws_local', applied: 'unknown' }, + }) + expect(request).toHaveBeenCalledTimes(1) + expect(console.log).not.toHaveBeenCalled() + }) + + it('matches the canonical request ID after the fork contract trims surrounding whitespace', async () => { + request.mockResolvedValue({ data: receipt }) + await expect( + executeOperation('pushWorkspace', CLI_CONTRACT.pushWorkspace!, V2_OPERATIONS.pushWorkspace, [ + { ...flags, requestId: ' original-request ', wait: true }, + new Command('leaf'), + ]) + ).resolves.toBeUndefined() + expect(request).toHaveBeenCalledTimes(1) + }) +}) + +describe('selector pagination metadata', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('preserves clipping reported by a later provider page in machine output', async () => { + request + .mockResolvedValueOnce({ + data: [{ id: 'first', label: 'First' }], + nextCursor: 'next-page', + truncated: false, + }) + .mockResolvedValueOnce({ + data: [{ id: 'second', label: 'Second' }], + nextCursor: null, + truncated: true, + }) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + await executeOperation('listSelector', CLI_CONTRACT.listSelector!, V2_OPERATIONS.listSelector, [ + { selectorKey: 'gmail.labels', context: '{"oauthCredential":"connection-1"}', limit: '0' }, + new Command('leaf'), + ]) + + expect(request).toHaveBeenCalledTimes(2) + expect(JSON.parse(stdout.mock.calls[0][0])).toEqual({ + data: [ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' }, + ], + nextCursor: null, + truncated: true, + }) + }) +}) + describe('an in-band run failure', () => { it('fails the process when a synchronous run reports status failed', async () => { request.mockResolvedValue({ diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 0cb78213ec5..06f78715585 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -1,7 +1,14 @@ +import { getErrorMessage } from '@sim/utils/errors' import type { Command } from 'commander' +import { + assertWorkspaceOperationOutcome, + readWorkspaceOperation, + waitWorkspaceOperation, + workspaceWaitTimeout, +} from '../commands/protocol/workspace-operation-wait' import { clientFrom } from '../context' import type { CommandSpec } from '../contract/types' -import type { V2OperationName } from '../generated/v2-api' +import type { GetWorkspaceOperationResponse, V2OperationName } from '../generated/v2-api' import { assertCursorAdvances, pageProgress, SimApiError, type V2Page } from '../http/client' import { safeOneLine } from '../output/render' import { camel } from './derive' @@ -17,6 +24,15 @@ import { import { foldPageEnvelope, renderPage, renderResult } from './result' import type { OperationSpec } from './types' +const WORKSPACE_OPERATION_KINDS: Readonly< + Partial> +> = { + importWorkflow: 'workflow_import', + forkWorkspace: 'workspace_fork', + pushWorkspace: 'workspace_push', + pullWorkspace: 'workspace_pull', +} + /** * Operations that report the outcome of the work they did in band. * @@ -385,12 +401,26 @@ export async function executeOperation( * on the caller knowing. */ const pagedLimit = paging ? readPagedLimit(requestFlags.limit, operation) : 0 - const request = buildRequest( - operation, - positional, - requestFlags, - needsWorkspace ? client.requireWorkspace() : profile.workspaceId - ) + const requestWorkspaceId = needsWorkspace ? client.requireWorkspace() : profile.workspaceId + const request = buildRequest(operation, positional, requestFlags, requestWorkspaceId) + + if (commandSpec.workspaceOperation) { + if (!WORKSPACE_OPERATION_KINDS[operation]) + throw new SimApiError('This command has no workspace operation identity configured', 0) + workspaceWaitTimeout(requestFlags.waitTimeout) + if (requestFlags.waitTimeout !== undefined && requestFlags.wait !== true) + throw new SimApiError('--wait-timeout requires --wait', 0) + if ( + requestFlags.wait === true && + (!request.body?.requestId || !request.body?.previewFingerprint) + ) + throw new SimApiError( + '--wait requires --request-id and --preview-fingerprint from the reviewed preview', + 0 + ) + if (operationSpec.body?.confirm && requestFlags.yes === true && request.body) + request.body.confirm = true + } if (paging) { const initialCursor = request[paging]?.cursor @@ -442,13 +472,86 @@ export async function executeOperation( return } - const result = await client.request<{ data?: unknown }>(request.path, { - method: operationSpec.method, - headers: request.headers, - query: request.query, - body: request.body, - }) - const payload = result?.data ?? result + let result: { data?: unknown } + try { + result = await client.request<{ data?: unknown }>(request.path, { + method: operationSpec.method, + headers: request.headers, + query: request.query, + body: request.body, + }) + } catch (error) { + if ( + commandSpec.workspaceOperation && + request.body?.requestId && + (!(error instanceof SimApiError) || + error.status === 0 || + error.status >= 500 || + (error.status >= 200 && error.status < 300)) + ) { + const failure = + error instanceof SimApiError + ? error + : new SimApiError(getErrorMessage(error, 'Unable to read the mutation response'), 0) + throw new SimApiError( + failure.message, + failure.status, + 'MUTATION_OUTCOME_UNKNOWN', + { + cause: failure.details, + requestId: request.body.requestId, + workspaceId: requestWorkspaceId, + applied: 'unknown', + reconciliation: + 'Find the operation using this requestId, or retry identical inputs with the same requestId.', + }, + failure.exitCode + ) + } + throw error + } + let payload = result?.data ?? result + if ( + commandSpec.workspaceOperation && + (Boolean(request.body?.requestId) || + requestFlags.wait === true || + (payload && typeof payload === 'object' && 'operationId' in payload)) + ) { + let report + try { + report = readWorkspaceOperation(payload) + const expectedRequestId = + operation !== 'importWorkflow' && typeof request.body?.requestId === 'string' + ? request.body.requestId.trim() + : request.body?.requestId + if ( + report.requestId !== expectedRequestId || + report.workspaceId !== requestWorkspaceId || + report.kind !== WORKSPACE_OPERATION_KINDS[operation] + ) + throw new SimApiError('The operation receipt does not match the submitted mutation', 0) + } catch { + throw new SimApiError( + 'The mutation response did not contain a matching operation receipt; reconcile using the same request ID', + 0, + 'MUTATION_OUTCOME_UNKNOWN', + { requestId: request.body?.requestId, workspaceId: requestWorkspaceId, applied: 'unknown' } + ) + } + let timedOut = false + if (requestFlags.wait === true) + ({ report, timedOut } = await waitWorkspaceOperation( + client, + report.workspaceId, + report.operationId, + workspaceWaitTimeout(requestFlags.waitTimeout), + report + )) + payload = report + renderResult(operation, profile.output, payload, commandSpec) + assertWorkspaceOperationOutcome(report, timedOut) + return + } renderResult( operation, profile.output, diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index c4dc2017431..fcbe18af859 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -294,6 +294,17 @@ export function addOperationOptions( } } + if (commandSpec.workspaceOperation) { + command.option( + '--wait', + 'Wait for the committed operation to finish; missing configuration and failure exit nonzero' + ) + command.option( + '--wait-timeout ', + 'Maximum operation wait in seconds (default 3600; 0 waits indefinitely)' + ) + } + if (commandSpec.confirm) { // There is no prompt to skip: a `confirm` command refuses outright when the // flag is absent, in a TTY or not. Calling it "Skip the confirmation" sent diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 4725f429638..a288f4a6e1f 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readSync } from 'node:fs' +import { closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec, FlagSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' @@ -141,15 +141,18 @@ function coerceRowCap(raw: unknown, flagName: string): { type: 'rows'; max: numb * `Atomics.wait` is the only synchronous sleep available; without it the retry * spins a core for as long as the writer takes. */ -function readStdin(): string { +export const MAX_JSON_ARGUMENT_BYTES = 10 * 1024 * 1024 + +function readArgumentDescriptor(descriptor: number): string { const idle = new Int32Array(new SharedArrayBuffer(4)) const buffer = Buffer.alloc(64 * 1024) const chunks: Buffer[] = [] + let bytes = 0 for (;;) { let read: number try { - read = readSync(0, buffer, 0, buffer.length, null) + read = readSync(descriptor, buffer, 0, buffer.length, null) } catch (error) { const code = (error as NodeJS.ErrnoException).code if (code === 'EAGAIN') { @@ -161,6 +164,8 @@ function readStdin(): string { throw error } if (read === 0) break + bytes += read + if (bytes > MAX_JSON_ARGUMENT_BYTES) throw new SimApiError('JSON input exceeds 10 MiB', 0) chunks.push(Buffer.from(buffer.subarray(0, read))) } @@ -199,6 +204,8 @@ function literalAtHint(error: unknown, path: string): string { } export function readArgumentSource(raw: string, flagName: string): { text: string; from: string } { + if (Buffer.byteLength(raw, 'utf8') > MAX_JSON_ARGUMENT_BYTES) + throw new SimApiError(`--${flagName} exceeds the 10 MiB JSON input limit`, 0) if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' } if (!raw.startsWith('@')) return { text: raw, from: '' } @@ -208,14 +215,21 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin throw new SimApiError(`--${flagName} @- reads stdin, but nothing is piped in`, 0) } try { - return { text: readStdin(), from: ' (read from stdin)' } + return { text: readArgumentDescriptor(0), from: ' (read from stdin)' } } catch (error) { throw new SimApiError(`--${flagName} cannot read stdin: ${(error as Error).message}`, 0) } } try { - return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } + const descriptor = openSync(path, 'r') + try { + if (fstatSync(descriptor).size > MAX_JSON_ARGUMENT_BYTES) + throw new SimApiError('JSON input exceeds 10 MiB', 0) + return { text: readArgumentDescriptor(descriptor), from: ` (read from ${path})` } + } finally { + closeSync(descriptor) + } } catch (error) { throw new SimApiError( `--${flagName} cannot read ${path}: ${(error as Error).message}${literalAtHint(error, path)}`, @@ -478,6 +492,15 @@ function asQueryValue(value: unknown): QueryValue { * API contract declares it in, so a field that moved from query to body moves * here on the next regeneration. */ +function boundedRequest(request: BuiltRequest): BuiltRequest { + if ( + request.body && + Buffer.byteLength(JSON.stringify(request.body), 'utf8') > MAX_JSON_ARGUMENT_BYTES + ) + throw new SimApiError('Aggregate JSON request body exceeds 10 MiB', 0) + return request +} + export function buildRequest( operation: V2OperationName, positional: string[], @@ -662,7 +685,12 @@ export function buildRequest( ) { throw new SimApiError(`--${variant.name} must be a JSON ${variant.kind}`, 0) } - return { path, query, body: { ...body, [variant.property]: parsed }, ...headerSlot } + return boundedRequest({ + path, + query, + body: { ...body, [variant.property]: parsed }, + ...headerSlot, + }) } const raw = flags.body @@ -671,10 +699,15 @@ export function buildRequest( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new SimApiError('--body must be a JSON object', 0) } - return { path, query, body: { ...body, ...(parsed as Record) }, ...headerSlot } + return boundedRequest({ + path, + query, + body: { ...body, ...(parsed as Record) }, + ...headerSlot, + }) } - return { + return boundedRequest({ path, query, /** @@ -683,5 +716,5 @@ export function buildRequest( */ body: spec.body ? body : undefined, ...headerSlot, - } + }) } diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index cde9032575b..a8c0d829c88 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -1,11 +1,12 @@ /** * @vitest-environment node */ +import { load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec } from '../contract/types' import { encodeFolderPath } from './request' -import { decodeFolderPath, renderPage, renderResult } from './result' +import { decodeFolderPath, foldPageEnvelope, renderPage, renderResult } from './result' let logged: string[] @@ -321,6 +322,63 @@ describe('folder paths are shown by name, but piped in wire form', () => { }) describe('paginated JSON output', () => { + it.each([ + { format: 'json', truncated: true }, + { format: 'json', truncated: false }, + { format: 'yaml', truncated: true }, + { format: 'yaml', truncated: false }, + ] as const)( + 'preserves truncated=$truncated in $format without carrying stale page data', + ({ format, truncated }) => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const page = { data: [{ id: 'a' }, { id: 'b' }], nextCursor: null } + renderPage(format, page, {}, { data: [{ id: 'a' }], nextCursor: 'stale', truncated }) + + const result = format === 'json' ? JSON.parse(logged.join('\n')) : load(logged.join('\n')) + expect(result).toEqual({ ...page, truncated }) + } + ) + + it.each([ + { first: false, last: true }, + { first: true, last: false }, + { first: undefined, last: false }, + ])('preserves truncation across pages with first=$first and last=$last', ({ first, last }) => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const envelope = foldPageEnvelope( + { + data: [{ id: 'a' }], + nextCursor: 'next', + ...(first === undefined ? {} : { toolNamesTruncated: first }), + }, + { data: [{ id: 'b' }], nextCursor: null, toolNamesTruncated: last } + ) + const page = { data: [{ id: 'a' }, { id: 'b' }], nextCursor: null } + renderPage('json', page, {}, envelope) + + expect(JSON.parse(logged.join('\n'))).toEqual({ + ...page, + toolNamesTruncated: first === true || last, + }) + }) + + it('projects only boolean truncation metadata from the envelope', () => { + const page = { data: [{ id: 'a' }], nextCursor: null } + renderPage( + 'json', + page, + {}, + { + truncated: 'true', + notTruncated: true, + isNotTruncated: true, + nested: { truncated: true }, + scope: 'unrelated metadata', + } + ) + expect(JSON.parse(logged.join('\n'))).toEqual(page) + }) + it('includes the cursor without a pagination notice', () => { const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) const page = { data: [{ id: 'a' }], nextCursor: 'c1' } diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 145999f75ea..892f70a2ee2 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -293,7 +293,7 @@ export function renderPage( format, page.data, spec.columns ? columnsFrom(spec.columns) : inferColumns(page.data, spec.expand), - page + { ...page, ...truncationMetadata(envelope) } ) } @@ -316,7 +316,7 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { * * Matched by shape rather than listed per command, so a flag added to a route * envelope is surfaced the day it lands. Structured list output carries data - * and nextCursor; truncation flags are reported separately. + * and nextCursor along with these boolean flags; human-readable warnings remain on stderr. */ const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ @@ -331,14 +331,24 @@ const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ */ const NEGATED_TRUNCATION_FLAG = /^(?:not|un|non|never)Truncated$|(?:Not|Un|Non|Never)Truncated$/ +/** Preserves declared boolean truncation fields without projecting user-owned row values. */ +function truncationMetadata(container: unknown): Record { + if (!container || typeof container !== 'object' || Array.isArray(container)) return {} + const metadata: Record = {} + for (const [key, value] of Object.entries(container)) + if ( + typeof value === 'boolean' && + TRUNCATION_FLAG.test(key) && + !NEGATED_TRUNCATION_FLAG.test(key) + ) + metadata[key] = value + return metadata +} + /** The flags one object raised, in the spelling the wire used. */ function truncationFlags(container: unknown): string[] { - if (!container || typeof container !== 'object' || Array.isArray(container)) return [] - return Object.entries(container) - .filter( - ([key, value]) => - value === true && TRUNCATION_FLAG.test(key) && !NEGATED_TRUNCATION_FLAG.test(key) - ) + return Object.entries(truncationMetadata(container)) + .filter(([, value]) => value) .map(([key]) => key) } @@ -368,12 +378,11 @@ function responseTruncationFlags(envelope: unknown): string[] { */ export function foldPageEnvelope(current: unknown, page: unknown): unknown { if (current === undefined) return page - const raised = truncationFlags(page) - if (raised.length === 0 || !current || typeof current !== 'object') return current - return { - ...(current as Record), - ...Object.fromEntries(raised.map((flag) => [flag, true])), - } + if (!current || typeof current !== 'object' || Array.isArray(current)) return current + const merged = { ...(current as Record) } + for (const [key, value] of Object.entries(truncationMetadata(page))) + merged[key] = merged[key] === true || value + return merged } /** `toolNamesTruncated` as a reader says it. */ diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 9e5126a982c..39997ae2bca 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -1078,6 +1078,10 @@ for (const [legacyOperationId, replacement] of Object.entries(LEGACY_CORE_REPLAC } const workflowMetaGroups = [ + { + tag: 'Workspace Sync', + file: 'content/docs/api-reference/(generated)/workspace-sync/meta.json', + }, { tag: 'Workflows', file: 'content/docs/api-reference/(generated)/workflows/meta.json', diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 24b8b923ee5..6cd86c50db8 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -44,6 +44,7 @@ export const GUIDE_PAGES = [ 'configuration', 'output', 'scripting', + 'workflow-sync', 'troubleshooting', ] as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 6f6a7f382fb..a8f42412331 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -108,7 +108,7 @@ async function routeApplicationOperation(routePath: string, method: string): Pro } const EXPECTED_OPERATION_COUNTS = new Map([ - ['apps/docs/openapi-v2-workflows.json', 38], + ['apps/docs/openapi-v2-workflows.json', 58], ['apps/docs/openapi-v2-logs.json', 3], ['apps/docs/openapi-v2-files-audit.json', 29], ['apps/docs/openapi-v2-tables.json', 53], @@ -310,7 +310,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(221) + expect(totalOperations).toBe(241) }) it('documents mixed workflow execution and resume responses', () => { @@ -323,6 +323,7 @@ describe('generated OpenAPI documents', () => { const executeQueuedContent = executeQueued.content as JsonObject expect((spec.tags as JsonObject[]).map((tag) => tag.name)).toEqual([ + 'Workspace Sync', 'Workflows', 'Workflow Runs', ]) diff --git a/scripts/test-workflow-sync.ts b/scripts/test-workflow-sync.ts new file mode 100644 index 00000000000..ce467ac7607 --- /dev/null +++ b/scripts/test-workflow-sync.ts @@ -0,0 +1,106 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' + +/** Runs workflow tests against a disposable PostgreSQL 17 container, never an application DSN. */ +const logger = createLogger('WorkflowSyncIntegration') +const root = resolve(import.meta.dir, '..') +const container = `sim-workflow-test-${generateId()}` +const fixtureEnv = { ...process.env } +for (const key of Object.keys(fixtureEnv)) { + if (key.startsWith('DATABASE_') || key === 'MIGRATION_DATABASE_URL') delete fixtureEnv[key] +} +function run(command: string, args: string[], cwd = root, capture = false): string { + const result = spawnSync(command, args, { + cwd, + env: fixtureEnv, + encoding: 'utf8', + stdio: capture ? 'pipe' : 'inherit', + }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${command} exited with ${result.status}`) + return result.stdout?.trim() ?? '' +} +let started = false +try { + run( + 'docker', + [ + 'run', + '--rm', + '--detach', + '--name', + container, + '--env', + 'POSTGRES_HOST_AUTH_METHOD=trust', + '--publish', + '127.0.0.1::5432', + 'pgvector/pgvector:pg17', + ], + root, + true + ) + started = true + let ready = false + for (let attempt = 0; attempt < 60; attempt++) { + const result = spawnSync( + 'docker', + ['exec', container, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres'], + { stdio: 'ignore' } + ) + if (result.status === 0) { + ready = true + break + } + await sleep(500) + } + if (!ready) throw new Error('Disposable PostgreSQL did not become ready') + run('docker', ['exec', container, 'createdb', '-U', 'postgres', 'sim_workflow_test']) + run('docker', [ + 'exec', + container, + 'psql', + '-U', + 'postgres', + '-d', + 'sim_workflow_test', + '-v', + 'ON_ERROR_STOP=1', + '-c', + 'CREATE EXTENSION vector; CREATE EXTENSION pg_trgm; CREATE EXTENSION btree_gin;', + ]) + const endpoint = run('docker', ['port', container, '5432/tcp'], root, true) + if (!/^127\.0\.0\.1:\d+$/.test(endpoint)) throw new Error('Unexpected fixture database endpoint') + const databaseUrl = `postgresql://postgres@${endpoint}/sim_workflow_test` + Object.assign(fixtureEnv, { DATABASE_URL: databaseUrl, WORKFLOW_TEST_DATABASE_URL: databaseUrl }) + run( + 'bun', + ['--no-env-file', 'x', 'drizzle-kit', 'push', '--config=./drizzle.config.ts', '--force'], + resolve(root, 'packages/db') + ) + run('docker', [ + 'exec', + container, + 'psql', + '-U', + 'postgres', + '-d', + 'sim_workflow_test', + '-v', + 'ON_ERROR_STOP=1', + '-c', + 'SELECT count(*) FROM workspace_operation_receipt', + ]) + run( + 'bun', + ['--no-env-file', 'x', 'vitest', 'run', '--config', 'vitest.workflows-integration.config.ts'], + resolve(root, 'apps/sim') + ) +} finally { + if (started) { + run('docker', ['stop', container], root, true) + logger.info('Removed disposable workflow database') + } +} From 57c1c7cd2f4442e0fd5a9167bed253929493954d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 18:03:26 -0700 Subject: [PATCH 12/30] fix(search): expose provider configuration updates in Sources (#7704) * fix(search): expose provider configuration updates in Sources * chore(search): document provider refresh option mapping --- .../self-hosting/integrations-oauth.mdx | 2 +- apps/docs/content/docs/search/github.mdx | 2 +- ...rganization-integrations-settings.test.tsx | 87 ++++++++++++++++++- .../organization-integrations-settings.tsx | 33 +++++-- .../organization-account-providers.tsx | 20 +---- .../organization-account-options.ts | 21 +++++ apps/sim/lib/credential-groups/service.ts | 4 +- 7 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/credential-groups/organization-account-options.ts diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index acc1fa6c884..3ca4d246c6f 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -203,7 +203,7 @@ Keep **Expire user authorization tokens** enabled so Sim receives the refresh to Complete the installation through [the GitHub Search source setup](/search/github#add-a-repository). -If you replace a deployment's GitHub App, an organization admin must first open **Settings → Connected accounts → Providers → Update configurations**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. +If you replace a deployment's GitHub App, an organization admin must first open **Settings → Sources → Update configurations**. When Search is disabled, this action is under **Settings → Connected accounts → Providers**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration.
diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 023068d0962..c4e661e1c7f 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -96,7 +96,7 @@ This is an installation plus personal authorization flow. GitHub Search does not | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | | Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | | Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | -| Update GitHub in Connected accounts before connecting this source | An organization admin must select **Settings → Connected accounts → Providers → Update configurations**, then reconnect GitHub. | +| Update GitHub using Update configurations in organization settings before connecting this source | An organization admin must select **Settings → Sources → Update configurations**, then reconnect GitHub. | | Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | | Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | | Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index 9ea2460bd81..b128245018e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ people: vi.fn(), invite: vi.fn(), refetch: vi.fn(), + update: vi.fn(), + updatePending: false, })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, @@ -21,6 +23,7 @@ vi.mock( ) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, + useUpdateOrganizationAccounts: () => ({ mutate: mocks.update, isPending: mocks.updatePending }), useOrganizationAccountPeople: mocks.people, useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }), useResendOrganizationAccountInvitation: () => ({}), @@ -37,10 +40,12 @@ describe('organization integration invitations', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(toast, 'success').mockReturnValue('toast-id') + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + mocks.updatePending = false vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ - data: { credentialGroup: { id: 'group-a' } }, + data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, refetch: mocks.refetch, }) @@ -93,7 +98,7 @@ describe('organization integration invitations', () => { it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { await render() expect(container.textContent).toContain('Provider setup') - expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') expect(mocks.people).not.toHaveBeenCalled() await click('People') @@ -119,6 +124,79 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) + it('refreshes saved provider identities from Sources and reports the outcome', async () => { + mocks.accounts.mockReturnValue({ + data: { + credentialGroup: { + id: 'group-a', + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + requiredScopes: ['search:read'], + }, + ], + }, + }, + error: null, + }) + mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) + await render() + await click('Update configurations') + expect(mocks.update).toHaveBeenCalledWith( + { + organizationId: 'org-a', + groupId: 'group-a', + update: { + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + requiredScopes: ['search:read'], + }, + ], + }, + }, + expect.any(Object) + ) + expect(toast.success).toHaveBeenCalledWith('Provider configurations updated') + + mocks.update.mockImplementationOnce((_input, { onError }) => + onError(new Error('Update denied')) + ) + await click('Update configurations') + expect(toast.error).toHaveBeenCalledWith('Update denied') + + mocks.updatePending = true + await render() + expect(findButton('Update configurations')).toBeDisabled() + await click('Update configurations') + expect(mocks.update).toHaveBeenCalledTimes(2) + }) + + it('does not offer a configuration update without saved providers', async () => { + await render() + expect(container.textContent).not.toContain('Update configurations') + }) + it('opens People directly from the saved URL', async () => { await render('?tab=people') expect(container.textContent).toContain('Request connections') @@ -137,7 +215,10 @@ describe('organization integration invitations', () => { await click('Request connections') expect(document.querySelector('[role="dialog"]')).toBeNull() - mocks.accounts.mockReturnValue({ data: { credentialGroup: { id: 'group-a' } }, error: null }) + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a', options: [] } }, + error: null, + }) await render('?tab=people') expect(container.textContent).not.toContain('Loading connected accounts') expect(findButton('Request connections')).not.toBeDisabled() diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index f0508c816a1..82055465391 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -1,7 +1,8 @@ 'use client' -import { Chip, ChipSwitch } from '@sim/emcn' +import { Chip, ChipSwitch, toast } from '@sim/emcn' import { useQueryState } from 'nuqs' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' @@ -10,7 +11,10 @@ import { SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' -import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' +import { + useOrganizationAccounts, + useUpdateOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' export function OrganizationIntegrationsSettings() { const { organization, viewer } = useOrganizationContext() @@ -18,9 +22,23 @@ export function OrganizationIntegrationsSettings() { organizationIntegrationsTabParam.key, organizationIntegrationsTabParam.parser ) - const accounts = useOrganizationAccounts( - viewer.isAdmin && tab === 'people' ? organization.id : undefined - ) + const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) + const update = useUpdateOrganizationAccounts() + const group = accounts.data?.credentialGroup + const updateConfigurations = () => { + if (!group || update.isPending) return + update.mutate( + { + organizationId: organization.id, + groupId: group.id, + update: { options: getOrganizationAccountUpdateOptions(group) }, + }, + { + onSuccess: () => toast.success('Provider configurations updated'), + onError: (error) => toast.error(error.message), + } + ) + } if (!viewer.isAdmin) return null return ( @@ -35,6 +53,11 @@ export function OrganizationIntegrationsSettings() { { value: 'people', label: 'People' }, ]} /> + {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( + + Update configurations + + )} {tab === 'providers' && } {tab === 'people' && ( diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index fb6cf96561a..60ecdd1c5dc 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -12,12 +12,10 @@ import { } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import type { - OrganizationAccountsSettings, - UpdateOrganizationAccountsBody, -} from '@/lib/api/contracts/organization-accounts' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { type CredentialGroupProvider, getCredentialGroupProviderService, @@ -60,19 +58,7 @@ export function OrganizationAccountProviders({ const addMcp = useAddOrganizationAccountMcpProvider() const removeMcp = useRemoveOrganizationAccountMcpProvider() const pending = update.isPending || addMcp.isPending || removeMcp.isPending - const options: NonNullable = group.options.map( - (option) => { - const common = { id: option.id, label: option.label, required: option.required } - return option.provider === 'slack' - ? { - ...common, - provider: 'slack', - slackBotCredentialId: option.slackBotCredentialId, - requiredScopes: option.requiredScopes, - } - : { ...common, provider: option.provider } - } - ) + const options = getOrganizationAccountUpdateOptions(group) const updateConfigurations = () => { if (pending) return update.mutate( diff --git a/apps/sim/lib/credential-groups/organization-account-options.ts b/apps/sim/lib/credential-groups/organization-account-options.ts new file mode 100644 index 00000000000..8716bdbc7d8 --- /dev/null +++ b/apps/sim/lib/credential-groups/organization-account-options.ts @@ -0,0 +1,21 @@ +import type { + OrganizationAccountsSettings, + UpdateOrganizationAccountsBody, +} from '@/lib/api/contracts/organization-accounts' + +/** Preserves option identities and custom Slack scopes while the server refreshes managed OAuth policies. */ +export function getOrganizationAccountUpdateOptions( + group: NonNullable +): NonNullable { + return group.options.map((option) => { + const common = { id: option.id, label: option.label, required: option.required } + return option.provider === 'slack' + ? { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + requiredScopes: option.requiredScopes, + } + : { ...common, provider: option.provider } + }) +} diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index d0d766f4e57..78715ba0884 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -306,7 +306,9 @@ export async function ensureWorkspaceAccountsGroup( ) { throw new OrchestrationError( 'validation', - `Update ${preparedOption.label} in Connected accounts before connecting this source` + scope.kind === 'organization' + ? `Update ${preparedOption.label} using Update configurations in organization settings before connecting this source` + : `Update ${preparedOption.label} in Connected accounts before connecting this source` ) } return existing From 65a04f1ee67c07f973477886d87fdd459f5aad83 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 18:29:47 -0700 Subject: [PATCH 13/30] fix(pii): preserve authorized access to restored large values (#7706) --- .../executor/execution/block-executor.test.ts | 59 +++++- apps/sim/executor/execution/block-executor.ts | 3 + .../workflows/executor/execution-core.test.ts | 196 +++++++++++++++++- .../lib/workflows/executor/execution-core.ts | 6 + 4 files changed, 261 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 250542ee7be..3d3808c5dc4 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -4,6 +4,7 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection' @@ -24,8 +25,10 @@ const blockExecutorBaseLogger = loggerMock.createLogger.mock.results[blockExecutorLoggerCallIndex]?.value if (!blockExecutorBaseLogger) throw new Error('BlockExecutor logger mock was not initialized') -const { mockUploadFile } = vi.hoisted(() => ({ +const { mockUploadFile, mockDownloadFile, mockMaskBatch } = vi.hoisted(() => ({ mockUploadFile: vi.fn(), + mockDownloadFile: vi.fn(), + mockMaskBatch: vi.fn(), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ @@ -35,9 +38,14 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ vi.mock('@/lib/uploads', () => ({ StorageService: { uploadFile: mockUploadFile, + downloadFile: mockDownloadFile, }, })) +vi.mock('@/lib/guardrails/mask-client', () => ({ + maskPIIBatchViaHttp: mockMaskBatch, +})) + vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => { const actual = await importOriginal() return { @@ -94,6 +102,55 @@ describe('BlockExecutor', () => { mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) }) + it('redacts an authorized prior-execution manifest returned by a block under the current execution', async () => { + const items = [{ email: 'alice@example.com', count: 7 }] + const manifest = await createLargeArrayManifest(items, { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'source-execution', + }) + clearLargeValueCacheForTests() + mockUploadFile.mockClear() + mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(items))) + mockMaskBatch.mockImplementation(async (texts: string[]) => + texts.map((text) => text.replaceAll('alice@example.com', '')) + ) + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const handler: BlockHandler = { + canHandle: () => true, + execute: async () => ({ result: manifest }), + } + const executor = new BlockExecutor([handler], resolver, {}, state) + const ctx = createContext(state) + ctx.largeValueExecutionIds = ['source-execution'] + ctx.piiBlockOutputRedaction = { enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' } + + await executor.execute(ctx, createNode(block), block) + + expect(state.getBlockOutput(block.id)?.result).toMatchObject({ + preview: [{ email: '', count: 7 }], + chunks: [{ ref: { executionId: 'execution-1' } }], + }) + expect(mockDownloadFile).toHaveBeenCalledWith( + expect.objectContaining({ key: manifest.chunks[0].ref.key }) + ) + expect(mockUploadFile).toHaveBeenCalledWith( + expect.objectContaining({ + customKey: expect.stringContaining('execution/workspace-1/workflow-1/execution-1/'), + file: Buffer.from(JSON.stringify([{ email: '', count: 7 }])), + }) + ) + }) + it('persists function output arrays as manifests in execution state', async () => { const block = createBlock() const workflow: SerializedWorkflow = { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 744f6b63123..87a799e6c0e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -358,6 +358,9 @@ export class BlockExecutor { workspaceId: blockCtx.workspaceId, workflowId: blockCtx.workflowId, executionId: blockCtx.executionId, + largeValueExecutionIds: blockCtx.largeValueExecutionIds, + largeValueKeys: blockCtx.largeValueKeys, + allowLargeValueWorkflowScope: blockCtx.allowLargeValueWorkflowScope, userId: blockCtx.userId, }, }) diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 76af01b6f07..b5bd98d9446 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -8,7 +8,14 @@ import { workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as retention from '@/lib/billing/retention' +import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' +import type { LargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest' +import type { LargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import type { LoggingSession } from '@/lib/logs/execution/logging-session' +import { ExecutionSnapshot } from '@/executor/execution/snapshot' +import type { SerializableExecutionState } from '@/executor/execution/types' const { mergeSubblockStateWithValuesMock, @@ -32,6 +39,9 @@ const { projectDisplayContentMock, projectDiagnosticErrorMock, decryptSecretMock, + downloadFileMock, + uploadFileMock, + maskBatchMock, } = vi.hoisted(() => ({ mergeSubblockStateWithValuesMock: vi.fn(), safeStartMock: vi.fn(), @@ -54,6 +64,9 @@ const { projectDisplayContentMock: vi.fn(), projectDiagnosticErrorMock: vi.fn(), decryptSecretMock: vi.fn(), + downloadFileMock: vi.fn(), + uploadFileMock: vi.fn(), + maskBatchMock: vi.fn(), })) const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv @@ -67,6 +80,19 @@ const loadWorkflowDeploymentVersionStateMock = workflowsPersistenceUtilsMockFns.mockLoadWorkflowDeploymentVersionState const updateWorkflowRunCountsMock = workflowsUtilsMockFns.mockUpdateWorkflowRunCounts +vi.mock('@/lib/uploads', () => ({ + StorageService: { downloadFile: downloadFileMock, uploadFile: uploadFileMock }, +})) + +vi.mock('@/lib/guardrails/mask-client', () => ({ + maskPIIBatchViaHttp: maskBatchMock, +})) + +vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({ + registerLargeValueOwner: vi.fn().mockResolvedValue(true), + addLargeValueReference: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/lib/execution/cancellation', () => ({ clearExecutionCancellation: clearExecutionCancellationMock, })) @@ -120,7 +146,7 @@ import { executeWorkflowCore, FINALIZED_EXECUTION_ID_TTL_MS, wasExecutionFinalizedByCore, -} from './execution-core' +} from '@/lib/workflows/executor/execution-core' const executionCoreLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex( ([name]) => name === 'ExecutionCore' @@ -993,6 +1019,172 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { expect(loadWorkflowDeploymentVersionStateMock).not.toHaveBeenCalled() }) + describe('PII redaction of restored large values', () => { + const sourceItems = [{ email: 'alice@example.com', count: 7 }] + const maskedItems = [{ email: '', count: 7 }] + const sourceBytes = Buffer.from(JSON.stringify(sourceItems)) + + function createManifest( + workspaceId = 'workspace-1', + workflowId = 'workflow-1', + executionId = 'source-execution' + ): LargeArrayManifest { + const ref: LargeValueRef = { + __simLargeValueRef: true, + version: 1, + id: 'lv_123456789012', + kind: 'array', + size: sourceBytes.length, + executionId, + key: `execution/${workspaceId}/${workflowId}/${executionId}/large-value-lv_123456789012.json`, + } + return { + __simLargeArrayManifest: true, + version: 2, + kind: 'array', + totalCount: 1, + chunkCount: 1, + byteSize: sourceBytes.length, + chunks: [{ ref, count: 1, byteSize: sourceBytes.length }], + preview: sourceItems, + } + } + + function createRestoredState(manifest: LargeArrayManifest): SerializableExecutionState { + return { + blockStates: { previous: { output: { result: manifest } } }, + executedBlocks: ['previous'], + blockLogs: [], + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: [], + trustedLargeValueAccess: { executionIds: [], largeValueKeys: [], fileKeys: [] }, + } + } + + function createPiiSnapshot(state?: SerializableExecutionState, input: unknown = {}) { + const base = createSnapshot() + return new ExecutionSnapshot( + { ...base.metadata, resumeFromSnapshot: state !== undefined }, + base.workflow, + input, + {}, + [], + state + ) + } + + beforeEach(() => { + clearLargeValueCacheForTests() + vi.spyOn(retention, 'resolveEffectivePiiRedaction').mockReturnValue({ + ...retention.DEFAULT_PII_REDACTION, + input: { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + customPatterns: [], + }, + blockOutputs: { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + customPatterns: [], + }, + }) + downloadFileMock.mockResolvedValue(sourceBytes) + uploadFileMock.mockImplementation(async ({ customKey }: { customKey: string }) => ({ + key: customKey, + })) + maskBatchMock.mockImplementation(async (texts: string[]) => + texts.map((text) => text.replaceAll('alice@example.com', '')) + ) + executorExecuteMock.mockResolvedValue({ + success: true, + status: 'completed', + output: { done: true }, + logs: [], + metadata: { duration: 1, startTime: 'start', endTime: 'end' }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + clearLargeValueCacheForTests() + }) + + it.each(['source execution', 'trusted key', 'resume', 'input'] as const)( + 'masks cached manifest content with %s access and stores it under the new execution', + async (mode) => { + const manifest = createManifest() + const state = createRestoredState(manifest) + if (mode === 'trusted key') + state.trustedLargeValueAccess!.largeValueKeys = [manifest.chunks[0].ref.key!] + const snapshot = createPiiSnapshot( + mode === 'resume' ? state : undefined, + mode === 'input' ? { result: manifest } : {} + ) + if (mode === 'input') snapshot.metadata.largeValueExecutionIds = ['source-execution'] + const result = await executeWorkflowCore({ + snapshot, + callbacks: {}, + loggingSession: loggingSession as unknown as LoggingSession, + ...(mode === 'resume' || mode === 'input' + ? {} + : { + runFromBlock: { + startBlockId: 'start-block', + sourceSnapshot: state, + sourceExecutionId: + mode === 'trusted key' ? 'intermediate-execution' : 'source-execution', + }, + }), + }) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + expect(result.success).toBe(true) + expect(executorExecuteMock).toHaveBeenCalledOnce() + expect(downloadFileMock).toHaveBeenCalledWith( + expect.objectContaining({ key: manifest.chunks[0].ref.key, maxBytes: 64 * 1024 * 1024 }) + ) + expect(uploadFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + customKey: expect.stringContaining('execution/workspace-1/workflow-1/execution-1/'), + file: Buffer.from(JSON.stringify(maskedItems)), + }) + ) + if (mode !== 'input') + expect(state.blockStates.previous.output).toMatchObject({ + result: { preview: maskedItems }, + }) + } + ) + + it.each([ + ['another workspace', 'workspace-2', 'workflow-1', 'source-execution'], + ['another workflow', 'workspace-1', 'workflow-2', 'source-execution'], + ['an unauthorized execution', 'workspace-1', 'workflow-1', 'unrelated-execution'], + ])( + 'refuses cached manifest content from %s before reading storage', + async (_, workspaceId, workflowId, executionId) => { + const state = createRestoredState(createManifest(workspaceId, workflowId, executionId)) + await expect( + executeWorkflowCore({ + snapshot: createPiiSnapshot(), + callbacks: {}, + loggingSession: loggingSession as unknown as LoggingSession, + runFromBlock: { + startBlockId: 'start-block', + sourceExecutionId: 'source-execution', + sourceSnapshot: state, + }, + }) + ).rejects.toThrow('Large execution value is not available in this execution.') + expect(downloadFileMock).not.toHaveBeenCalled() + expect(uploadFileMock).not.toHaveBeenCalled() + expect(executorExecuteMock).not.toHaveBeenCalled() + } + ) + }) + it('marks inherited client run-from-block provenance incomplete', async () => { executorExecuteMock.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 886f7e8c40c..82ba64adbc1 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -888,6 +888,9 @@ async function executeWorkflowCoreImpl( workspaceId: providedWorkspaceId, workflowId, executionId, + largeValueExecutionIds, + largeValueKeys, + allowLargeValueWorkflowScope, userId: userId ?? undefined, }, }) @@ -918,6 +921,9 @@ async function executeWorkflowCoreImpl( workspaceId: providedWorkspaceId, workflowId, executionId, + largeValueExecutionIds, + largeValueKeys, + allowLargeValueWorkflowScope, userId: userId ?? undefined, }, } From 73875ae2a3724c0fed7d3d575de9adb18a884b78 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 18:39:42 -0700 Subject: [PATCH 14/30] fix(search): omit response-only scopes from provider updates (#7707) --- ...rganization-integrations-settings.test.tsx | 1 - .../organization-account-providers.test.tsx | 1 - .../organization-account-options.test.ts | 64 +++++++++++++++++++ .../organization-account-options.ts | 3 +- .../sim/lib/credential-groups/service.test.ts | 8 ++- 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/credential-groups/organization-account-options.test.ts diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index b128245018e..3c91acc8103 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -170,7 +170,6 @@ describe('organization integration invitations', () => { label: 'Slack', required: false, slackBotCredentialId: 'slack-bot', - requiredScopes: ['search:read'], }, ], }, diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx index 5bb56a7e043..0610a6bc7ea 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.test.tsx @@ -259,7 +259,6 @@ describe('organization provider configuration UI', () => { label: slack.label, required: slack.required, slackBotCredentialId: slack.slackBotCredentialId, - requiredScopes: slack.requiredScopes, }, ], }, diff --git a/apps/sim/lib/credential-groups/organization-account-options.test.ts b/apps/sim/lib/credential-groups/organization-account-options.test.ts new file mode 100644 index 00000000000..d8b8d81e212 --- /dev/null +++ b/apps/sim/lib/credential-groups/organization-account-options.test.ts @@ -0,0 +1,64 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { + type OrganizationAccountsSettings, + updateOrganizationAccountsContract, +} from '@/lib/api/contracts/organization-accounts' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' +import { CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS } from '@/lib/credential-groups/providers' + +describe('organization account update options', () => { + it.each([undefined, '12345678-1234-4123-8123-123456789012'])( + 'satisfies the update contract with stored Slack scopes and bot %s', + (slackBotCredentialId) => { + const group: NonNullable = { + id: 'group-1', + workspaceId: null, + organizationId: 'org-1', + name: 'Connected accounts', + description: null, + mcpServers: [], + status: 'active', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + options: [ + ...CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS.map((provider) => ({ + id: `${provider}-option`, + provider, + label: provider, + required: false, + status: 'active' as const, + configurationStatus: 'ready' as const, + })), + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: true, + status: 'active', + configurationStatus: 'ready', + slackBotCredentialId, + requiredScopes: ['search:read', 'channels:history'], + }, + ], + } + + const options = getOrganizationAccountUpdateOptions(group) + const result = updateOrganizationAccountsContract.body.parse({ options }) + + expect(result.options).toEqual(options) + expect(result.options?.map(({ id }) => id)).toEqual(group.options.map(({ id }) => id)) + expect(result.options?.at(-1)).toEqual({ + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: true, + slackBotCredentialId, + }) + expect(group.options.at(-1)).toHaveProperty('requiredScopes', [ + 'search:read', + 'channels:history', + ]) + } + ) +}) diff --git a/apps/sim/lib/credential-groups/organization-account-options.ts b/apps/sim/lib/credential-groups/organization-account-options.ts index 8716bdbc7d8..f8bf3091c18 100644 --- a/apps/sim/lib/credential-groups/organization-account-options.ts +++ b/apps/sim/lib/credential-groups/organization-account-options.ts @@ -3,7 +3,7 @@ import type { UpdateOrganizationAccountsBody, } from '@/lib/api/contracts/organization-accounts' -/** Preserves option identities and custom Slack scopes while the server refreshes managed OAuth policies. */ +/** Keeps option IDs so the server preserves saved scopes when refreshing provider policies. */ export function getOrganizationAccountUpdateOptions( group: NonNullable ): NonNullable { @@ -14,7 +14,6 @@ export function getOrganizationAccountUpdateOptions( ...common, provider: 'slack', slackBotCredentialId: option.slackBotCredentialId, - requiredScopes: option.requiredScopes, } : { ...common, provider: option.provider } }) diff --git a/apps/sim/lib/credential-groups/service.test.ts b/apps/sim/lib/credential-groups/service.test.ts index b648f861ec5..7c956d2015c 100644 --- a/apps/sim/lib/credential-groups/service.test.ts +++ b/apps/sim/lib/credential-groups/service.test.ts @@ -105,7 +105,7 @@ describe('Credential Group service', () => { } ) - it('validates provider policy through the active update transaction', async () => { + it('preserves stored scopes while validating provider policy in the update transaction', async () => { const option = { id: 'option-1', provider: 'slack' as const, @@ -156,8 +156,12 @@ describe('Credential Group service', () => { }) ).resolves.toMatchObject({ id: 'group-1' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ options: [option] })) expect(mockGetPolicy).toHaveBeenCalledWith( - expect.objectContaining({ slackBotCredentialId: 'bot-1' }), + expect.objectContaining({ + slackBotCredentialId: 'bot-1', + requiredScopes: option.requiredScopes, + }), { workspaceId: 'workspace-1', credentialGroupId: 'group-1', From d4d11d963904507d89d6f9d32d1a60b6f790f58b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 19:16:02 -0700 Subject: [PATCH 15/30] fix(search): authorize organization source credentials (#7708) --- .../application/organization-credentials.ts | 1 + .../knowledge/application/connector-access.ts | 9 + .../application/connector-credential.test.ts | 198 ++++++++++++++++++ .../application/connector-credential.ts | 42 ++++ .../knowledge/application/connectors.test.ts | 3 + .../lib/knowledge/application/connectors.ts | 41 ++-- .../github-installation-source.test.ts | 32 +-- .../application/github-installation-source.ts | 18 +- 8 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 apps/sim/lib/knowledge/application/connector-credential.test.ts create mode 100644 apps/sim/lib/knowledge/application/connector-credential.ts diff --git a/apps/sim/lib/credentials/application/organization-credentials.ts b/apps/sim/lib/credentials/application/organization-credentials.ts index 599ada36385..82d8bbe900d 100644 --- a/apps/sim/lib/credentials/application/organization-credentials.ts +++ b/apps/sim/lib/credentials/application/organization-credentials.ts @@ -263,6 +263,7 @@ export async function authorizeOrganizationCredentialUse(input: { const row = await getOrganizationCredential(input.organizationId, input.credentialId) if ( !row || + row.revokedAt || (row.type !== 'oauth' && row.type !== 'service_account') || !row.providerId || (row.type === 'oauth' && row.createdBy !== context.userId) diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index aded446945e..c2986b43410 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -234,6 +234,9 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ } const previousConfig = connector.sourceConfig as Record const sourceConfig = await prepareGitHubInstallationSource({ + principal, + requestId, + workspaceId: context.workspaceId, connectorType: connector.connectorType, credentialId: input.credentialId === undefined && input.accessMode === connector.accessMode @@ -271,6 +274,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ } if (credentialId) { await requireUsableCredential({ + principal, credentialId, connectorMeta, sourceConfig, @@ -280,6 +284,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ accessMode: 'members', }) const rejection = await validateConnectorSourceConfig({ + principal, connector: { ...connector, accessMode: 'members', credentialId }, sourceConfig, ...owner, @@ -299,6 +304,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ target = { accessMode: input.accessMode, credentialId: await requireUsableCredential({ + principal, credentialId: input.credentialId, connectorMeta, sourceConfig, @@ -309,6 +315,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ }), } const rejection = await validateConnectorSourceConfig({ + principal, connector: { ...connector, accessMode: target.accessMode, @@ -372,6 +379,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ * source validation verifies it against the target mode before any mutation. */ async function requireUsableCredential(input: { + principal: Principal credentialId: string | null | undefined connectorMeta: Pick sourceConfig: Record @@ -403,6 +411,7 @@ async function requireUsableCredential(input: { ) } const token = await resolveConnectorCredentialAccessToken({ + principal: input.principal, credentialId: input.credentialId, ...resourceScopeFields(resourceScopeFromOwner(input)), actingUserId: input.actingUserId, diff --git a/apps/sim/lib/knowledge/application/connector-credential.test.ts b/apps/sim/lib/knowledge/application/connector-credential.test.ts new file mode 100644 index 00000000000..5852292eb70 --- /dev/null +++ b/apps/sim/lib/knowledge/application/connector-credential.test.ts @@ -0,0 +1,198 @@ +/** @vitest-environment node */ +import type { Principal } from '@sim/auth/principal' +import { credential, member } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { and, eq, isNull } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + config: vi.fn(), + catalog: vi.fn(), + requireService: vi.fn(), + requireOAuth: vi.fn(), + repository: vi.fn(), +})) +vi.mock('@/lib/permission-groups/resolve.server', () => ({ + getUserPermissionConfigForOrganization: mocks.config, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.catalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireService, + requireAvailableOAuthCredentialProvider: mocks.requireOAuth, +})) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + throwCredentialMutationFailure: vi.fn(), +})) +vi.mock('@/lib/credentials/orchestration/credential-create', () => ({ + createCredentialRecord: vi.fn(), +})) +vi.mock('@/lib/credentials/orchestration', () => ({ updateCredentialRecord: vi.fn() })) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: vi.fn(), + getActiveConnectDraft: vi.fn(), +})) +vi.mock('@/lib/oauth/credential-service', () => ({ resolveCredentialTokenBundle: vi.fn() })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: async () => ({ decrypted: '{}' }), +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + parseGitHubInstallationBinding: () => ({ installationId: '42', accountId: '7' }), + resolveGitHubInstallationRepository: mocks.repository, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireConnectorCredential } from '@/lib/knowledge/application/connector-credential' +import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' + +const principal: Principal = { kind: 'session', userId: 'admin-1', sessionId: 'session-1' } +const installed = { + id: 'installation-credential', + organizationId: 'org-1', + workspaceId: null, + type: 'service_account', + providerId: 'github-app-installation', + createdBy: 'installer-1', + revokedAt: null, + encryptedServiceAccountKey: 'encrypted', + providerSubjectId: '42', + providerTenantId: '7', +} +const input = { + principal, + credentialId: installed.id, + scope: { kind: 'organization' as const, organizationId: 'org-1' }, + actingUserId: 'admin-1', + requestId: 'request-1', +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.config.mockResolvedValue(null) + mocks.catalog.mockResolvedValue([]) + mocks.repository.mockResolvedValue({ id: '123', fullName: 'example/private' }) +}) + +describe('organization source credential authorization', () => { + it.each(['owner', 'admin'])('pins an installation repository for a current %s', async (role) => { + queueTableRows(member, [{ role }]) + queueTableRows(credential, [installed]) + + await expect( + prepareGitHubInstallationSource({ + principal, + requestId: input.requestId, + connectorType: 'github', + credentialId: installed.id, + organizationId: 'org-1', + isSearchIndex: true, + accessMode: 'members', + actingUserId: 'admin-1', + sourceConfig: { repository: 'example/private' }, + }) + ).resolves.toEqual({ repository: 'example/private', githubRepositoryId: '123' }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + and(eq(member.organizationId, 'org-1'), eq(member.userId, 'admin-1')) + ) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + and( + eq(credential.id, installed.id), + and(eq(credential.organizationId, 'org-1'), isNull(credential.workspaceId)) + ) + ) + expect(mocks.requireService).toHaveBeenCalledWith([], 'github-app-installation') + }) + + it.each([{ rows: [] }, { rows: [{ role: 'member' }] }])( + 'refuses missing or insufficient membership: %j', + async ({ rows }) => { + queueTableRows(member, rows) + queueTableRows(credential, [installed]) + await expect(requireConnectorCredential(input)).rejects.toBeInstanceOf(OrchestrationError) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(credential) + expect(mocks.catalog).not.toHaveBeenCalled() + } + ) + + it('does not substitute the credential creator or attributed user for the principal', async () => { + queueTableRows(member, []) + await expect( + requireConnectorCredential({ ...input, actingUserId: installed.createdBy }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + and(eq(member.organizationId, 'org-1'), eq(member.userId, principal.userId)) + ) + }) + + it('refuses a credential outside the asserted organization', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, []) + await expect(requireConnectorCredential(input)).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.catalog).not.toHaveBeenCalled() + }) + + it('does not make an organization credential usable from a workspace', async () => { + queueTableRows(credential, [installed]) + await expect( + requireConnectorCredential({ ...input, scope: { kind: 'workspace', workspaceId: 'ws-1' } }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.catalog).not.toHaveBeenCalled() + }) + + it('refuses revoked credentials before provider access', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [{ ...installed, revokedAt: new Date() }]) + await expect(requireConnectorCredential(input)).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.catalog).not.toHaveBeenCalled() + }) + + it('does not let an admin use another person’s OAuth account', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [{ ...installed, type: 'oauth', providerId: 'github-repositories' }]) + await expect(requireConnectorCredential(input)).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('allows an admin to use their own organization OAuth account', async () => { + const ownAccount = { + ...installed, + type: 'oauth', + providerId: 'github-repositories', + createdBy: 'admin-1', + } + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [ownAccount]) + await expect(requireConnectorCredential(input)).resolves.toEqual(ownAccount) + expect(mocks.requireOAuth).toHaveBeenCalledWith([], 'github-repositories') + }) + + it('enforces the existing integration-management capability', async () => { + queueTableRows(member, [{ role: 'admin' }]) + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideIntegrationsTab: true, + }) + await expect(requireConnectorCredential(input)).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(credential) + }) + + it('refuses workspace keys before protected loading', async () => { + await expect( + requireConnectorCredential({ + ...input, + principal: { kind: 'workspace_api_key', workspaceId: 'ws-1', keyId: 'key-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(dbChainMockFns.from).not.toHaveBeenCalled() + }) + + it('propagates provider policy denials', async () => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(credential, [installed]) + const denial = new OrchestrationError('forbidden', 'Provider is unavailable') + mocks.requireService.mockImplementationOnce(() => { + throw denial + }) + await expect(requireConnectorCredential(input)).rejects.toBe(denial) + }) +}) diff --git a/apps/sim/lib/knowledge/application/connector-credential.ts b/apps/sim/lib/knowledge/application/connector-credential.ts new file mode 100644 index 00000000000..514917095df --- /dev/null +++ b/apps/sim/lib/knowledge/application/connector-credential.ts @@ -0,0 +1,42 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ResourceScope, + resourceScopeFromOwner, + sameResourceScope, +} from '@/lib/core/resource-scope' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { authorizeOrganizationCredentialUse } from '@/lib/credentials/application/organization-credentials' +import type { CredentialRow } from '@/lib/credentials/queries' + +/** Resolves source credentials using the authorization policy of their canonical owner scope. */ +export async function requireConnectorCredential(input: { + principal: Principal + credentialId: string + scope: ResourceScope + actingUserId: string + requestId: string +}): Promise { + if (input.scope.kind === 'organization') { + const { credential } = await authorizeOrganizationCredentialUse({ + principal: input.principal, + organizationId: input.scope.organizationId, + credentialId: input.credentialId, + requestId: input.requestId, + }) + return credential + } + + const access = await getCredentialActorContext(input.credentialId, input.actingUserId) + if ( + !access.credential || + !sameResourceScope(resourceScopeFromOwner(access.credential), input.scope) || + !canUseCredential(access) + ) { + throw new OrchestrationError( + 'validation', + 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.' + ) + } + return access.credential +} diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 652c8b40e18..e36e516f69f 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -268,6 +268,7 @@ describe('knowledge connector application use cases', () => { async (accessMode) => { await expect( resolveConnectorCredentialAccessToken({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, credentialId: 'credential-1', workspaceId: 'workspace-a', actingUserId: 'admin', @@ -285,6 +286,7 @@ describe('knowledge connector application use cases', () => { mocks.resolveTokenIdentity.mockResolvedValueOnce({ kind: 'service_account' }) await expect( resolveConnectorCredentialAccessToken({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, credentialId: 'credential-1', workspaceId: 'workspace-a', actingUserId: 'admin', @@ -316,6 +318,7 @@ describe('knowledge connector application use cases', () => { } as Parameters[0]['connector'] await expect( validateConnectorSourceConfig({ + principal: { kind: 'session', userId: 'admin', sessionId: 'session' }, connector, sourceConfig: { adminEmail: 'admin@corp.com' }, workspaceId: 'workspace-a', diff --git a/apps/sim/lib/knowledge/application/connectors.ts b/apps/sim/lib/knowledge/application/connectors.ts index e8adeb13283..e98a4817de6 100644 --- a/apps/sim/lib/knowledge/application/connectors.ts +++ b/apps/sim/lib/knowledge/application/connectors.ts @@ -25,14 +25,9 @@ import { type ResourceScope, resourceScopeFields, resourceScopeFromOwner, - sameResourceScope, } from '@/lib/core/resource-scope' import { generateRequestId } from '@/lib/core/utils/request' -import { - canUseCredential, - getCredentialActorContext, - resolveCredentialTokenIdentity, -} from '@/lib/credentials/access' +import { resolveCredentialTokenIdentity } from '@/lib/credentials/access' import { requireKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope' @@ -42,6 +37,7 @@ import { resolveKnowledgeAttributedUserId, resolveKnowledgeBillingAttribution, } from '@/lib/knowledge/application/billing' +import { requireConnectorCredential } from '@/lib/knowledge/application/connector-credential' import { type ActiveKnowledgeResourceBaseContext, resolveActiveKnowledgeConnectorContext, @@ -243,6 +239,8 @@ export function requireConnectorWorkspaceId(context: ActiveKnowledgeResourceBase } async function resolveAuthorizedConnectorCredentialIdentity(input: { + principal: Principal + requestId: string credentialId: string workspaceId?: string organizationId?: string @@ -251,21 +249,14 @@ async function resolveAuthorizedConnectorCredentialIdentity(input: { auth: ConnectorAuthConfig accessMode: string }) { - const access = await getCredentialActorContext(input.credentialId, input.actingUserId) - if ( - !access.credential || - !sameResourceScope(resourceScopeFromOwner(access.credential), resourceScopeFromOwner(input)) || - !canUseCredential(access) - ) { - throw new OrchestrationError( - 'validation', - 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.' - ) - } + const credential = await requireConnectorCredential({ + ...input, + scope: resourceScopeFromOwner(input), + }) if ( input.service && - (!access.credential.providerId || - !credentialProviderMatchesService(access.credential.providerId, input.service)) + (!credential.providerId || + !credentialProviderMatchesService(credential.providerId, input.service)) ) { throw new OrchestrationError( 'validation', @@ -291,6 +282,7 @@ async function resolveAuthorizedConnectorCredentialIdentity(input: { * of another provider. */ export async function resolveConnectorCredentialAccessToken(input: { + principal: Principal credentialId: string workspaceId?: string organizationId?: string @@ -315,6 +307,7 @@ export async function resolveConnectorCredentialAccessToken(input: { } export async function validateConnectorSourceConfig(input: { + principal: Principal connector: KnowledgeConnectorRow sourceConfig: Record workspaceId?: string @@ -375,6 +368,8 @@ export async function validateConnectorSourceConfig(input: { } } const identity = await resolveAuthorizedConnectorCredentialIdentity({ + principal: input.principal, + requestId: input.requestId, credentialId: input.connector.credentialId, workspaceId: input.workspaceId, organizationId: input.organizationId, @@ -767,6 +762,9 @@ async function executeCreateKnowledgeConnector( } } const sourceConfig = await prepareGitHubInstallationSource({ + principal, + requestId, + workspaceId: context.workspaceId, connectorType: input.connectorType, credentialId: input.credentialId, organizationId: context.organizationId, @@ -792,6 +790,7 @@ async function executeCreateKnowledgeConnector( resolveKnowledgeBillingAttribution(principal, context), resolveAccessToken: (credentialId) => resolveConnectorCredentialAccessToken({ + principal, credentialId, ...owner, actingUserId, @@ -941,6 +940,9 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ updates: input.updates, prepareSourceConfig: (connector, sourceConfig) => prepareGitHubInstallationSource({ + principal, + requestId, + workspaceId: context.workspaceId, connectorType: connector.connectorType, credentialId: connector.credentialId, organizationId: context.organizationId, @@ -960,6 +962,7 @@ export const updateKnowledgeConnector = defineAuthorizedKnowledgeUseCase({ validateSourceConfig: (connector, sourceConfig) => { const owner = resourceScopeFields(resourceScopeFromOwner(context)) return validateConnectorSourceConfig({ + principal, connector, sourceConfig, ...owner, diff --git a/apps/sim/lib/knowledge/application/github-installation-source.test.ts b/apps/sim/lib/knowledge/application/github-installation-source.test.ts index 5eaee58ae6c..2c7666faf04 100644 --- a/apps/sim/lib/knowledge/application/github-installation-source.test.ts +++ b/apps/sim/lib/knowledge/application/github-installation-source.test.ts @@ -3,14 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const m = vi.hoisted(() => ({ access: vi.fn(), - canUse: vi.fn(), decrypt: vi.fn(), parse: vi.fn(), repository: vi.fn(), })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: m.access, - canUseCredential: m.canUse, +vi.mock('@/lib/knowledge/application/connector-credential', () => ({ + requireConnectorCredential: m.access, })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: m.decrypt })) vi.mock('@/lib/oauth/github-installation', () => ({ @@ -18,6 +16,7 @@ vi.mock('@/lib/oauth/github-installation', () => ({ resolveGitHubInstallationRepository: m.repository, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { prepareGitHubInstallationSource } from '@/lib/knowledge/application/github-installation-source' const installed = { @@ -32,6 +31,8 @@ const installed = { providerTenantId: '7', } const input = { + principal: { kind: 'session' as const, userId: 'admin', sessionId: 'session' }, + requestId: 'request', connectorType: 'github', credentialId: installed.id, organizationId: 'org', @@ -43,8 +44,7 @@ const input = { beforeEach(() => { vi.clearAllMocks() - m.access.mockResolvedValue({ credential: installed }) - m.canUse.mockReturnValue(true) + m.access.mockResolvedValue(installed) m.decrypt.mockResolvedValue({ decrypted: '{}' }) m.parse.mockReturnValue({ installationId: '42', accountId: '7' }) m.repository.mockResolvedValue({ id: '123', fullName: 'example/private', defaultBranch: 'main' }) @@ -58,10 +58,16 @@ describe('GitHub installation source identity', () => { sourceConfig: { ...input.sourceConfig, githubRepositoryId: '999' }, }) ).resolves.toEqual({ repository: 'example/private', githubRepositoryId: '123' }) - expect(m.access).toHaveBeenCalledWith(installed.id, 'admin') + expect(m.access).toHaveBeenCalledWith( + expect.objectContaining({ + principal: input.principal, + credentialId: installed.id, + scope: { kind: 'organization', organizationId: 'org' }, + }) + ) }) it.each([ - { organizationId: undefined }, + { organizationId: undefined, workspaceId: 'workspace' }, { isSearchIndex: false }, { accessMode: 'admin' }, { accessMode: 'workspace' }, @@ -78,22 +84,20 @@ describe('GitHub installation source identity', () => { { revokedAt: new Date() }, { encryptedServiceAccountKey: null }, ])('refuses unusable or cross-scope installation credentials: %j', async (change) => { - m.access.mockResolvedValue({ credential: { ...installed, ...change } }) + m.access.mockResolvedValue({ ...installed, ...change }) await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ code: 'forbidden', }) expect(m.decrypt).not.toHaveBeenCalled() }) it('refuses credentials the acting user cannot use', async () => { - m.canUse.mockReturnValue(false) + m.access.mockRejectedValue(new OrchestrationError('forbidden', 'Credential access denied')) await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ code: 'forbidden', }) }) it('bounds encrypted binding data before decryption', async () => { - m.access.mockResolvedValue({ - credential: { ...installed, encryptedServiceAccountKey: 'x'.repeat(16_385) }, - }) + m.access.mockResolvedValue({ ...installed, encryptedServiceAccountKey: 'x'.repeat(16_385) }) await expect(prepareGitHubInstallationSource(input)).rejects.toMatchObject({ code: 'validation', }) @@ -122,7 +126,7 @@ describe('GitHub installation source identity', () => { prepareGitHubInstallationSource({ ...input, previousConfig: { githubRepositoryId: '123' } }) ).resolves.toMatchObject({ githubRepositoryId: '123' }) }) - it.each([null, { credential: { providerId: 'github-repositories' } }])( + it.each([null, { providerId: 'github-repositories' }])( 'cannot downgrade an existing installation by replacing or deleting its credential', async (access) => { m.access.mockResolvedValue(access) diff --git a/apps/sim/lib/knowledge/application/github-installation-source.ts b/apps/sim/lib/knowledge/application/github-installation-source.ts index c289b4accc4..8ee2daa57b4 100644 --- a/apps/sim/lib/knowledge/application/github-installation-source.ts +++ b/apps/sim/lib/knowledge/application/github-installation-source.ts @@ -1,6 +1,8 @@ +import type { Principal } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret } from '@/lib/core/security/encryption' -import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { requireConnectorCredential } from '@/lib/knowledge/application/connector-credential' import { parseGitHubInstallationBinding, resolveGitHubInstallationRepository, @@ -8,6 +10,9 @@ import { import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' interface GitHubInstallationSourceInput { + principal: Principal + requestId: string + workspaceId?: string connectorType: string credentialId?: string | null organizationId?: string @@ -32,10 +37,13 @@ export async function prepareGitHubInstallationSource( ) return input.sourceConfig } - const access = input.credentialId - ? await getCredentialActorContext(input.credentialId, input.actingUserId) + const contentCredential = input.credentialId + ? await requireConnectorCredential({ + ...input, + credentialId: input.credentialId, + scope: resourceScopeFromOwner(input), + }) : null - const contentCredential = access?.credential if (contentCredential?.providerId !== GITHUB_INSTALLATION_PROVIDER_ID) { if (wasInstallation || assertedId !== undefined) throw new OrchestrationError( @@ -50,8 +58,6 @@ export async function prepareGitHubInstallationSource( 'GitHub installations require organization Search with connected member access' ) if ( - !access || - !canUseCredential(access) || contentCredential.organizationId !== input.organizationId || contentCredential.workspaceId !== null || contentCredential.type !== 'service_account' || From fbb5c70b27720019778e169deaf1ef3a64b821df Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 9 Sep 2026 19:59:20 -0700 Subject: [PATCH 16/30] fix(search): harden connector setup and indexed access (#7710) * fix(search): harden connector setup and indexed access * fix(search): persist verified Gmail size skips --- apps/docs/content/docs/search/confluence.mdx | 28 +- .../docs/search/connect-your-account.mdx | 35 +- apps/docs/content/docs/search/github.mdx | 8 +- apps/docs/content/docs/search/gitlab.mdx | 10 +- apps/docs/content/docs/search/gmail.mdx | 38 +- .../content/docs/search/google-calendar.mdx | 24 +- .../docs/content/docs/search/google-drive.mdx | 17 +- apps/docs/content/docs/search/index.mdx | 62 +-- apps/docs/content/docs/search/jira.mdx | 16 +- apps/docs/content/docs/search/mcp.mdx | 52 ++ apps/docs/content/docs/search/meta.json | 1 + apps/docs/content/docs/search/slack.mdx | 60 +- .../public/static/search/confluence-setup.jpg | Bin 35214 -> 24808 bytes .../public/static/search/connect-account.png | Bin 153886 -> 100713 bytes .../static/search/integration-provider.jpg | Bin 14091 -> 21510 bytes .../static/search/integration-settings.jpg | Bin 115160 -> 56193 bytes .../docs/public/static/search/slack-setup.jpg | Bin 46590 -> 39338 bytes .../public/static/search/source-settings.jpg | Bin 50434 -> 37543 bytes .../static/search/source-sync-history.jpg | Bin 28372 -> 19147 bytes .../(auth)/verify/use-verification.test.tsx | 102 ++++ .../sim/app/(auth)/verify/use-verification.ts | 7 +- .../oauth/route.test.ts | 71 ++- .../organization-credentials/oauth/route.ts | 14 +- .../slack-managed-users/route.test.ts | 10 +- .../enroll/[token]/page.test.tsx | 6 +- .../credential-groups/enroll/[token]/page.tsx | 16 +- .../components/get-started/get-started.tsx | 43 +- .../home/organization-home.test.tsx | 106 +++- .../organization-search-mcp.test.tsx | 17 +- .../components/organization-search-mcp.tsx | 10 +- .../components/search-mcp-connection.tsx | 28 +- .../[connectorType]/provider-detail.test.tsx | 2 +- .../[connectorType]/provider-detail.tsx | 6 - .../components/source-card/source-card.tsx | 3 +- .../components/source-chip/index.ts | 7 +- .../source-chip/source-chip.test.tsx | 81 +++ .../components/source-chip/source-chip.tsx | 11 +- .../components/special-tags/special-tags.tsx | 6 +- .../message-content/resolve-citations.test.ts | 31 ++ .../connect-service-account-modal.test.tsx | 133 +++++ .../connect-service-account-modal.tsx | 4 +- .../add-connector-modal.test.tsx | 62 ++- .../add-connector-modal.tsx | 1 + .../connector-access-field.test.tsx | 42 +- .../connector-access-field.tsx | 32 +- .../connector-config-fields.tsx | 8 + .../connector-selector-field.test.tsx | 50 +- .../connector-selector-field.tsx | 9 + .../connector-sync-history.tsx | 66 +-- .../connectors-section.test.tsx | 69 ++- .../connector-settings-fields.test.tsx | 187 ++++++- .../connector-settings-fields.tsx | 11 +- .../use-connector-settings-form.test.tsx | 39 ++ .../components/search-source-setup.test.tsx | 36 +- apps/sim/components/settings/navigation.ts | 1 + apps/sim/connectors/auth.test.ts | 34 ++ apps/sim/connectors/auth.ts | 3 +- .../connectors/confluence/confluence.test.ts | 206 +++++++ apps/sim/connectors/confluence/confluence.ts | 31 +- apps/sim/connectors/confluence/meta.ts | 1 + .../connectors/confluence/permissions.test.ts | 217 ++++++++ apps/sim/connectors/confluence/permissions.ts | 71 ++- apps/sim/connectors/github/meta.ts | 1 - apps/sim/connectors/gitlab/gitlab.test.ts | 45 ++ apps/sim/connectors/gitlab/gitlab.ts | 38 +- apps/sim/connectors/gmail/gmail.test.ts | 517 +++++++++++++++++- apps/sim/connectors/gmail/gmail.ts | 195 ++++++- .../google-calendar/google-calendar.test.ts | 4 +- .../google-calendar/google-calendar.ts | 2 +- .../google-drive/google-drive.test.ts | 19 +- apps/sim/connectors/jira/jira.test.ts | 143 +++++ apps/sim/connectors/jira/jira.ts | 114 +++- apps/sim/connectors/slack/slack.test.ts | 48 ++ apps/sim/connectors/slack/slack.ts | 8 +- apps/sim/connectors/types.ts | 2 +- apps/sim/hooks/queries/credential-groups.ts | 2 - .../sim/hooks/queries/oauth-provider.test.tsx | 9 + apps/sim/hooks/queries/oauth-provider.ts | 7 +- .../queries/oauth/oauth-credentials.test.ts | 30 +- .../hooks/queries/oauth/oauth-credentials.ts | 31 +- apps/sim/lib/api/contracts/credentials.ts | 2 +- apps/sim/lib/api/contracts/knowledge/slack.ts | 2 +- .../api/contracts/organization-accounts.ts | 2 +- .../api/contracts/organization-credentials.ts | 9 +- apps/sim/lib/auth/connectors/managed-oauth.ts | 4 +- apps/sim/lib/auth/oauth-provider.test.ts | 4 +- apps/sim/lib/auth/oauth-provider.ts | 6 +- .../sim/lib/copilot/chat/citation-evidence.ts | 6 +- .../lib/copilot/chat/retrieval-citations.ts | 1 + .../lib/copilot/generated/docs-manifest.ts | 1 + .../server/knowledge/workspace-search.test.ts | 31 ++ .../server/knowledge/workspace-search.ts | 4 + .../provider-registry.test.ts | 14 + .../slack-managed-users.test.ts | 121 ++++ .../credential-groups/slack-managed-users.ts | 4 +- .../organization-credentials.test.ts | 299 +++++++++- .../application/organization-credentials.ts | 98 +++- .../sim/lib/credentials/managed-oauth.test.ts | 44 ++ .../credentials/organization-managed.test.ts | 120 ++++ .../lib/credentials/organization-managed.ts | 73 +++ .../excluded-member-documents.integration.ts | 254 +++++++++ .../gmail-member.integration.ts | 2 +- .../jira-member.integration.ts | 126 ++++- .../listing-continuation.integration.ts | 4 +- ...rganization-search-overview.integration.ts | 4 +- .../access/drive-permissions.test.ts | 23 +- .../lib/knowledge/access/drive-permissions.ts | 7 +- .../knowledge/application/connectors.test.ts | 225 ++++++++ .../lib/knowledge/application/connectors.ts | 4 +- .../knowledge/application/contexts.test.ts | 101 ++++ .../sim/lib/knowledge/application/contexts.ts | 1 + .../knowledge/connectors/access-token.test.ts | 34 +- .../lib/knowledge/connectors/access-token.ts | 13 +- .../connectors/member-sync-engine.ts | 7 +- .../connectors/sync-content-pass.test.ts | 248 ++++++++- .../knowledge/connectors/sync-content-pass.ts | 45 +- apps/sim/lib/knowledge/mcp/server.test.ts | 11 + apps/sim/lib/knowledge/mcp/server.ts | 14 +- apps/sim/lib/oauth/types.ts | 2 +- apps/sim/lib/oauth/utils.ts | 7 +- apps/sim/lib/organizations/surface.test.ts | 39 ++ apps/sim/lib/organizations/surface.ts | 4 + .../lib/selectors/server/credentials.test.ts | 77 +++ apps/sim/lib/selectors/server/credentials.ts | 2 + .../server/providers/confluence.test.ts | 6 + .../selectors/server/providers/confluence.ts | 13 +- .../providers/credential-bundle.test.ts | 38 ++ .../server/providers/credential-bundle.ts | 29 +- .../selectors/server/providers/google.test.ts | 152 ++++- .../lib/selectors/server/providers/google.ts | 52 +- .../selectors/server/providers/jira.test.ts | 3 + .../lib/selectors/server/providers/jira.ts | 3 +- 132 files changed, 5313 insertions(+), 538 deletions(-) create mode 100644 apps/docs/content/docs/search/mcp.mdx create mode 100644 apps/sim/app/(auth)/verify/use-verification.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx create mode 100644 apps/sim/connectors/auth.test.ts create mode 100644 apps/sim/lib/credentials/organization-managed.test.ts create mode 100644 apps/sim/lib/credentials/organization-managed.ts create mode 100644 apps/sim/lib/knowledge/__integration__/excluded-member-documents.integration.ts diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index 70276ed311f..e2480e8a74d 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -17,10 +17,10 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co | Method | Who supplies the content? | What teammates do | | --- | --- | --- | -| **Central account** | One account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. | +| **Service account** | One service account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. | | **Member accounts** | Sim syncs content separately through connected members' accounts. | Connect their own Confluence account to establish which pages they can access. | -Selecting **Add source** on Confluence's integration page opens central account setup. Use this when one account can read the intended spaces and their permissions. For a member source, start **Connect account** from **Integrations** in the main sidebar after an admin allows Confluence. Available methods depend on your organization's enabled features. +Selecting **Add source** on Confluence's integration page opens service-account setup. Use this when a service account can read the intended spaces and their permissions. For a member source, start **Connect** from **Integrations** in the main sidebar after an admin allows Confluence. Available methods depend on your organization's enabled features. **Everyone still connects in both methods.** With a central account, teammates supply their identity; they do not configure another central crawl or choose spaces again. @@ -40,14 +40,14 @@ On hosted Sim, personal connections authorize the existing Sim app. Teammates do ### Choose Confluence -Open **Settings → Sources** and turn on **Confluence** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. **Setup guide** opens this guide from the source form. +Open **Settings → Sources** and turn on **Confluence**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. **Setup guide** opens this guide from the source form. -### Select an account +### Select a service account -Under **Indexing account**, select an existing account, choose **Connect Confluence account** for OAuth, or add a service account using the [steps below](#using-a-service-account). The account must be able to read the content and its permissions. +Under **Indexing account**, select an existing service account or add one using the [steps below](#using-a-service-account). It must be able to read the content and its permissions. Personal OAuth accounts are used in the member connection flow. @@ -60,9 +60,9 @@ Open **More options** to change **Content Type**, **Filter by Label**, or **Meta Confluence central source setup with an indexing account, domain, and spaces @@ -70,7 +70,7 @@ Open **More options** to change **Content Type**, **Filter by Label**, or **Meta ### Save and connect your identity -Click **Connect & Sync**. Then open **Integrations** in the main sidebar, click **Connect account** on the Confluence source, and finish the connection in the new tab. Sign in using the Atlassian email that matches your verified Sim email, and authorize the configured site. +Click **Connect & Sync**. Then open **Integrations** in the main sidebar, click **Connect** on the Confluence source, and finish the connection in the new tab. Sign in using the Atlassian email that matches your verified Sim email, and authorize the configured site. Each teammate completes this last step. A previously authorized account may already be connected. Return to Integrations to see indexing status and your searchable document count. @@ -79,7 +79,7 @@ Each teammate completes this last step. A previously authorized account may alre ## Connect member accounts -After an admin allows Confluence, open **Integrations** in the main sidebar and select **Connect account**. If there is no source yet, enter **Confluence Domain** and **Space Keys**, then select **Connect** and authorize your account. For another site or space scope, use **Add source** beside **Add another Confluence source**. +After an admin allows Confluence, open **Integrations** in the main sidebar and select **Connect**. If there is no source yet, enter **Confluence Domain** and **Space Keys**, then select **Connect** and authorize your account. For another site or space scope, select **Connect** beside the Confluence row labeled **Connect a different site or content scope**. An admin can open **Settings → Sources**, select **Manage** beside **Confluence**, and open the source's **Settings** tab to adjust its filters. **Account for browsing** helps populate the space picker; it does not connect that account for Search. Manual space keys work without a browsing account. @@ -147,7 +147,7 @@ Under **Indexing account**, choose the service-account connection action. Paste Scopes do not grant access to spaces or pages by themselves. Keep the account's Confluence permissions and its token scopes aligned. When a token expires or needs different scopes, create a replacement in Atlassian. Add the replacement service account in the source's **Settings**, then use **Change indexing account** to apply it. -Personal OAuth uses Sim's shared Confluence integration and requests a broader set of permissions, including writes. Search reads content and permissions; it does not edit your Confluence pages. Older OAuth connections need to reconnect to grant the group-read permission used by central permission syncing. +Personal OAuth uses Sim's shared Confluence integration and requests a broader set of permissions, including writes. Search reads content and permissions; it does not edit your Confluence pages. ## Configuration @@ -164,7 +164,7 @@ Search manages the schedule and hides item limits. Published/current content is ## Teammates and ongoing sync -Existing organization members see the configured Confluence source and their own **Connect account** or **Reconnect** action. Add new teammates through your Sim organization invitation or SSO onboarding, then have them connect Confluence from Integrations. Connecting a Confluence account does not add someone to the Sim organization. +Existing organization members see the configured Confluence source and their own **Connect** or **Reconnect** action. Add new teammates through your Sim organization invitation or SSO onboarding, then have them connect Confluence from Integrations. Connecting a Confluence account does not add someone to the Sim organization. With a central account, Sim applies space access together with the page's restrictions and inherited ancestor restrictions. Group membership is refreshed in the background. With member accounts, each person's provider listing determines the pages available to them. A Sim organization admin does not automatically receive access to every Confluence document. @@ -174,7 +174,7 @@ New content and permission changes require a sync and processing before Search r | What you see | What to check | | --- | --- | -| **Connect & Sync** is disabled | Select a central account, enter the domain, and choose at least one space. | +| **Connect & Sync** is disabled | Select a service account, enter its site domain, and choose at least one space. | | Space picker is empty | Connect an account, enter the correct domain, and verify its space access. You can also switch to manual space keys. | | Service-account validation fails | Check the token's expiry, site, Confluence app access, and scopes. Use a scoped API token from an Atlassian service account. | | Content syncs but central search returns nothing | Connect your personal Confluence identity. Ask the admin to check directory/permission sync errors and group-read scopes. | @@ -190,12 +190,12 @@ On Confluence Premium, **Inspect permissions** can show where a user's access is ## Self-hosted operator setup -Configure one shared Confluence OAuth integration for your deployment. This powers personal identity connections in both Search methods and the optional central OAuth account. +Configure one shared Confluence OAuth integration for your deployment. This powers personal connections in both Search methods. Central indexing uses a service-account token. 1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create your deployment's **OAuth 2.0 integration**. 2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` to **Callback URLs**, keep existing callbacks used by the deployment, and save. 3. Under **Permissions**, add the Confluence API and configure the full `confluence` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The service-account read scopes above do not replace the broader shared OAuth scope set. 4. Enable sharing under **Distribution**. Set `CONFLUENCE_CLIENT_ID` and `CONFLUENCE_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start authorization from **Integrations** and select the configured site. Reconnect old accounts after adding scopes so the new permission grant takes effect. +5. Start authorization from **Integrations** and select the configured site. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. A callback mismatch needs a corrected callback URL; a connection that works only for the app owner needs sharing enabled. See Atlassian's [OAuth configuration guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 025f7414568..25e02ec92fe 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -21,23 +21,20 @@ Accept your Sim organization invitation or sign in through your organization's S ## Open Integrations -Open **Integrations** in the main sidebar, find the provider or source, and select **Connect account**. Your first connection may ask for a GitHub repository, Confluence domain and space keys, or Jira domain and project keys. Enter the required fields and select **Connect**. If the provider is missing, ask an organization admin to turn it on under **Settings → Sources → Allowed in Sim Search**. +Open **Integrations** in the main sidebar, find the provider or source, and select **Connect**. Your first connection may ask for a GitHub repository, Confluence domain and space keys, or Jira domain and project keys. Enter the required fields and select **Connect**. If the provider is missing, ask an organization admin to turn it on under **Settings → Sources**. -Use **Add source** beside **Add another [provider] source** when you need another supported repository, site, or project scope. Connecting an existing source does not ask you to configure it again. - -Organization Integrations showing approved providers and Connect account actions +To connect another supported repository, site, or project scope, find the provider row labeled **Connect a different site or content scope** and select **Connect**. Connecting an existing source does not ask you to configure it again. +Organization Integrations with search, personal connections, and Connect actions ## Authorize your account -In the new tab, select **Connect** and complete the provider's authorization. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. - -Return to Integrations when the connection completes, or select **Return to Search** to open your organization’s Search page. Your account is saved when authorization completes; there is no separate submit step. If the popup was blocked or closed, allow popups and select **Connect account** again. While authorization is pending, use **Open again**. +Complete the provider's authorization in the new tab. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. -Gmail account connection with Connect and Return to Search actions +The authorization tab closes when the connection completes and Integrations updates. If the tab stays open, return to Integrations. Your account is saved when authorization completes; there is no separate submit step. If the popup was blocked or closed, allow popups and select **Connect** again. While authorization is pending, use **Open again**. @@ -49,7 +46,7 @@ The source row shows indexing status and how many documents are available to you -For a source configured inside a workspace, join that workspace and use its **Search** page instead. Organization and workspace sources are separate. +For a source configured inside a workspace, join that workspace and connect through its **Search** page. To find documents, open **Home** and select **Search** in the composer. Organization and workspace sources are separate. ## Do I always need to connect? @@ -57,36 +54,38 @@ For a source configured inside a workspace, join that workspace and use its **Se | --- | --- | | Member accounts | Connect your own account, including when you are the admin. | | GitHub App installation | Connect GitHub once for this Sim organization. The App handles indexing; your account establishes which repositories you may search. | -| Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. | +| Confluence service account | Connect Confluence to verify your identity; the service account handles the crawl. | | Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. | -| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. | +| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match the confirmed primary GitLab email. | Connecting one Google service does not connect all of them. Gmail, Calendar, and Drive each have their own Search connection. ## If you received a connection request -Open the link from your admin and sign in to Sim with the invited email. A provider-specific request opens that provider's connection directly. Verify your Sim email if prompted, then reopen the original link and authorize the account. +Open the link from your admin and sign in to Sim with the invited email. A provider-specific request opens a Sim connection page for that provider; select **Connect** to start authorization. Verify your Sim email if prompted. Sim returns you to the connection page to authorize your account; if you are not redirected, reopen the original link. -An account connection request does not invite you into the Sim organization. You can contribute an account without organization membership, but you need membership and enabled Search access to search the organization's documents. The connection page offers **Return to Search** when you have that access; otherwise it offers **Your connected accounts**. +An account connection request does not invite you into the Sim organization. You can contribute an account without organization membership, but you need membership and enabled Search access to search the organization's documents. A provider-specific request offers **Return to Search** when you have that access; otherwise it offers **Open Sim**. A request covering several providers lists their connection options and a **Submit** button. Each account is saved as soon as its authorization completes. + +Gmail account connection with Connect and Return to Search actions ## Manage your connected accounts -Select **Your accounts** on the main Integrations page to open your personal **Connected accounts** settings. Use **Reconnect** to renew an organization account connection or **Disconnect** to withdraw it. Disconnecting stops that account from being used for organization indexing and workflows, and removes Search access that depends on it. +On the main **Integrations** page, use **Reconnect** beside an expired connection to renew it. To withdraw an account, open its row's actions menu and select **Disconnect**, then confirm. If several accounts are connected, choose the account to disconnect. Disconnecting stops that account from being used for organization indexing and workflows, and removes Search access that depends on it. -Admins can select **Manage sources** to open organization setup, then select **Manage** beside an integration to open its **Sources** and **Accounts** tabs. This does not grant the admin access to every document. +Admins manage setup from **Settings → Sources**. Select **Manage** beside the integration; Google providers have **Accounts** and **Advanced** tabs, while other providers with personal connections have **Sources** and **Accounts**. This does not grant the admin access to every document. ## If you get stuck | Status | What to do | | --- | --- | -| **Connect account** | Complete the connection in the new tab. | +| **Connect** | Complete the connection in the new tab. | | **Reconnect** | Authorize the same source account again. | | **Finish connecting in the other tab** | Finish authorization, or use **Open again**. Allow popups for Sim. | | No results | Check the source's filters and sync status with your admin. Confirm you can open the document at the source. | -| **Verify email** | Verify your Sim email, then reopen the connection link. | +| **Verify email** | Verify your Sim email to return to the connection page. Reopen the original link if you are not redirected. | | Expired or cancelled authorization | Return to the original connection page and start again. If the invitation itself expired, ask the admin for a new request. | | Access revoked | Ask the organization admin to restore your account contribution access before reconnecting. | -| Needs admin attention | Ask your admin to open **Settings → Sources**, select **Manage** beside the integration, and open the source to inspect its error. | +| Needs admin attention | Ask your admin to open **Settings → Sources**, select **Manage** beside the integration, and open the source from **Advanced** (Google providers) or **Sources** to inspect its error. | Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you. diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index c4e661e1c7f..cf3f2858cc9 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -25,7 +25,7 @@ To connect an installation for central indexing, you must be a Sim organization ### Open GitHub setup -Open **Settings → Sources** and turn on **GitHub** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. +Open **Settings → Sources** and turn on **GitHub**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. @@ -65,13 +65,13 @@ Open **More options** if you need a different branch, path or extension filters, ### Connect your account -Open **Integrations** in the main sidebar, select **Connect account** on the GitHub source, and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). +Open **Integrations** in the main sidebar, select **Connect** on the GitHub source, and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). An App installation or dedicated indexing account can start syncing after the source is saved. With **Connected members**, indexing begins after someone connects. Each teammate still connects before searching. An existing GitHub connection in the same Sim organization is reused across its GitHub sources. Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. -Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also use **Add another GitHub source** in the main Integrations page. +Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also select **Connect** beside the GitHub row labeled **Connect a different site or content scope** in Integrations. @@ -94,7 +94,7 @@ This is an installation plus personal authorization flow. GitHub Search does not | No eligible installations found | Finish connecting your own GitHub account, install the configured App on your account or an organization you own, then select **Refresh**. An installation of a different App or one you only have repository access to cannot be selected. | | Repository is not accepted for an installation | Check `owner/repo`, the installation's account and repository selection, and your own access. Update the source's Repository field after a rename. After a transfer, add a source using an installation for the new owner. | | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | -| Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | +| Authorization fails after installation | Return to Sim and start **Connect** there. Do not enable authorization during installation. | | Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | | Update GitHub using Update configurations in organization settings before connecting this source | An organization admin must select **Settings → Sources → Update configurations**, then reconnect GitHub. | | Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 69789c73e36..39fb464de65 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -7,7 +7,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed GitLab email. +GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed primary GitLab email. GitLab source setup in Sim Search @@ -49,7 +49,7 @@ The token must read the project, users, inherited project membership, instance s ### Configure the source in Sim -Open **Settings → Sources** and turn on **GitLab** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. +Open **Settings → Sources** and turn on **GitLab**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. | Field | What to enter | |---|---| @@ -59,7 +59,7 @@ Open **Settings → Sources** and turn on **GitLab** under **Allowed in Sim Sear | Branch | Optional branch or tag for repository files; blank uses the project's default branch. | | Path Filter / File Extensions | Optional limits for repository files. | | Issue State / Labels / Milestone | Optional filters for issues. | -| Max Items | Optional positive limit. Leave blank for all matching items. | +| Max Items | Optional positive whole-number limit. Leave blank for all matching items. | **More options** contains the repository and issue filters, **Max Items**, and **Metadata tags**. Unlike member-account sources, GitLab retains this optional item limit. @@ -72,7 +72,7 @@ Select **Connect & Sync**. Sim validates the token and source policy, then start ### Let teammates search -Invite teammates to the Sim organization using their verified work email. Sim matches that email against the GitLab directory and applies project, feature, and confidential-issue permissions. No GitLab **Connect account** step is required. +Invite teammates to the Sim organization using their verified work email. Sim matches that email against their confirmed primary GitLab email and applies project, feature, and confidential-issue permissions. Confirmed secondary addresses are not matched. No GitLab **Connect** step is required. Admins open **Settings → Sources**, select **Manage** beside **GitLab**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. GitLab has no personal **Accounts** tab. Permission and membership changes are picked up during background refreshes. @@ -90,7 +90,7 @@ The connector supports text repository files, wiki pages, issues, merge requests | Administrator token required | Use an active instance administrator's PAT with `read_api`, plus `admin_mode` when required. A project or group token cannot replace it. | | Source permissions cannot be mirrored | Read the reported policy. Sim rejects unsupported external authorization, IP restrictions, download-ban policies, or session-specific step-up requirements. | | Project not found | Check the host, project path or ID, and token access. | -| A teammate sees no results | Confirm both accounts' verified/confirmed email addresses match and the user has the required GitLab project or feature access. | +| A teammate sees no results | Confirm their verified Sim email matches their confirmed primary GitLab email and the user has the required GitLab project or feature access. | | Token expired | Remove and add the source again with a new token. This connector does not support replacing its token in place or refreshing PATs automatically. | Custom GitLab roles may grant more access than Sim's conservative role mapping recognizes. A source requiring unsupported policies must remain unavailable until its access model can be represented accurately. diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index 1af4d9a514d..94fd942d937 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -11,30 +11,34 @@ Search email threads from your own Gmail account. An organization admin enables Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. -## Set up the source - -These steps require a Sim organization admin. +## Set up Gmail -### Set up Gmail +### Allow Gmail -Open **Settings → Sources** and turn on **Gmail** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Gmail uses member accounts; there is no domain-wide or service-account crawl in Search. +Open **Settings → Sources** and turn on **Gmail**. Gmail uses member accounts; there is no domain-wide or service-account crawl in Search. -### Choose what to include +### Connect your account -Keep the defaults to search all dates and labels, excluding Promotions, Social, Spam, and Trash. **Labels** and **Date Range** are shown first. Open **More options** for category exclusions, **Search Filter**, and **Metadata tags**. +Open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The first connection creates the default sync configuration: all dates and labels, excluding Promotions, Social, Spam, and Trash. -### Create the source +### Adjust filters if needed + +An admin selects **Manage** beside Gmail in **Settings → Sources**, opens **Advanced**, then selects the configuration's **Settings** tab. Change **Labels**, **Date Range**, or other filters and save. + +To create a separate configuration, use **Add sync configuration** on **Advanced**. Its form shows **Labels** and **Date Range** first; **More options** contains category exclusions, **Search Filter**, and **Metadata tags**. Select **Add source** to save. This does not connect accounts or invite people. + +One configuration is usually enough. Adding another creates a separate source; editing **Settings** updates the selected one. Every configuration applies to all active Gmail connections, including accounts connected later. It does not assign different filters to selected people or let teammates search each other's mail. -Click **Add source**. Gmail appears in the provider's **Sources** list. Each person, including the admin, then connects their own account from **Integrations** in the main sidebar. To send a Gmail connection request, open **Gmail → Accounts → Request connections**; this does not invite the recipient to the Sim organization. +Configurations are additive: a narrower one does not restrict an existing broader one, and overlapping configurations can index the same thread more than once. For one organization-wide policy, edit the existing configuration. @@ -43,15 +47,15 @@ Click **Add source**. Gmail appears in the provider's **Sources** list. Each per ## Connect your account -1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect account** beside Gmail. +1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect** beside Gmail. 2. Complete the connection in the tab that opens. Choose the Google account whose verified email matches your Sim email, and grant the requested permissions. 3. Return to Integrations. The source shows its indexing status and the number of documents you can search. -Teammates follow these same steps after joining the organization. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand. +Teammates follow these same steps after joining the organization. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand or request connections from **Manage → Accounts → Request connections**. A connection request does not invite the recipient to the Sim organization. ## Source options -An admin opens **Settings → Sources**, selects **Manage** beside **Gmail**, and opens the source's **Settings** tab to change these options. Filters apply separately to each connected mailbox. **Documents** shows indexed threads and **Sync history** shows recent runs. +An admin opens **Settings → Sources**, selects **Manage** beside **Gmail**, opens **Advanced**, and selects the configuration's **Settings** tab to change these options. Filters apply separately to each connected mailbox. **Documents** shows indexed threads and **Sync history** shows recent runs. | Option | Behavior | | --- | --- | @@ -70,24 +74,30 @@ File attachments and image contents are not indexed. Thread discovery uses Gmail Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search. +An empty mailbox or filters with no matching threads complete normally with zero documents. + +Threads that exceed indexing size limits are skipped and reconsidered when the thread changes. + ## Troubleshooting | What you see | What to do | | --- | --- | | A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. | | No searchable documents | Check the source's labels, date range, category exclusions, and search filter. Allow the first sync to finish. | -| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect account** again. | +| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect** again. | | Reconnect | Click **Reconnect** and authorize the same account again. | | Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | ## Self-hosted operator setup +For an External app in **Testing**, Google refresh tokens for these scopes expire after seven days. Before production use, configure the appropriate publishing status and complete any required verification; adding test users alone does not make a durable production connection. See [Google’s token expiration rules](https://developers.google.com/identity/protocols/oauth2#expiration). + Users do not need to create Google Cloud credentials. The deployment operator configures one Google OAuth client for the instance: 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it. 2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience. 3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-email diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index fecad2266eb..60a676b3f45 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -11,32 +11,30 @@ Search meetings and event details available to your Google account. An organizat Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. -## Set up the source - -These steps require a Sim organization admin. +## Set up Google Calendar -### Set up Google Calendar +### Allow and connect Google Calendar -Open **Settings → Sources** and turn on **Google Calendar** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Search uses member accounts; an admin or service account cannot connect on behalf of everyone. +An admin opens **Settings → Sources** and turns on **Google Calendar**. Then each person opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes their matching Google account. The first connection creates the default sync configuration. Search uses member accounts; an admin or service account cannot connect on behalf of everyone. -### Choose the calendars +### Choose calendars if needed -Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. +An admin selects **Manage** beside Google Calendar in **Settings → Sources**, opens **Advanced**, and selects the configuration's **Settings** tab. Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. **Account for browsing** only helps you choose calendars. It does not connect your account for Search or grant teammates access. -### Create the source +### Save or add a configuration -Keep the default date range for the previous and next 30 days. **More options** contains **Search Query**, **Include Attendees**, and **Metadata tags**. Click **Add source**, then connect your own account from **Integrations** in the main sidebar. +The default date range covers the previous and next 30 days. Save any changes to the existing configuration. To create a separate one, select **Add sync configuration** on **Advanced**. In that form, **More options** contains **Search Query**, **Include Attendees**, and **Metadata tags**. Select **Add source** to save it; teammates connect their own accounts from Integrations. @@ -49,7 +47,7 @@ Keep the default date range for the previous and next 30 days. **More options** ## Connect your account -1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect account** beside Google Calendar. +1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect** beside Google Calendar. 2. In the connection tab, choose the Google account whose verified email matches your Sim email. Grant the requested permissions. 3. Return to Integrations to see indexing status and your searchable document count. @@ -57,7 +55,7 @@ Teammates repeat only these connection steps after joining the organization. The ## Source options -An admin opens **Settings → Sources**, selects **Manage** beside **Google Calendar**, and opens the source's **Settings** tab to change these options. **Documents** shows indexed events and **Sync history** shows recent runs. +An admin opens **Settings → Sources**, selects **Manage** beside **Google Calendar**, opens **Advanced**, and selects the configuration's **Settings** tab to change these options. **Documents** shows indexed events and **Sync history** shows recent runs. | Option | Behavior | | --- | --- | @@ -89,12 +87,14 @@ Search schedules syncs hourly. Event edits, cancellations, access changes, and e ## Self-hosted operator setup +For an External app in **Testing**, Google refresh tokens for these scopes expire after seven days. Before production use, configure the appropriate publishing status and complete any required verification; adding test users alone does not make a durable production connection. See [Google’s token expiration rules](https://developers.google.com/identity/protocols/oauth2#expiration). + The deployment operator configures Google OAuth once; teammates then use the normal connection flow. 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Google Calendar API**, and enable it. 2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users. Add test users while an external app is testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent). 3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add the URI below under **Authorized redirect URIs**. Add it to the existing Google client if the instance already uses one. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-calendar diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index 3f68cebe7c7..d502865f1d4 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -15,7 +15,7 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co | Method | Use it when | What teammates do | | --- | --- | --- | -| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts after the source is created. | +| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts. | | **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Sign in to Sim with matching verified email addresses; no personal Drive connection is needed for this source. | @@ -29,21 +29,21 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co ### Allow Google Drive -An organization admin opens **Settings → Sources** and turns on **Google Drive** under **Allowed in Sim Search**. This allows personal connections; it does not create a source or connect anyone's account. +An organization admin opens **Settings → Sources** and turns on **Google Drive**. This allows personal connections; it does not create a source or connect anyone's account. ### Connect your account -Open **Integrations** in the main sidebar and select **Connect account** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Teammates follow the same [connection steps](/search/connect-your-account). +Open **Integrations** in the main sidebar and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Teammates follow the same [connection steps](/search/connect-your-account). ### Adjust filters if needed -An admin opens **Settings → Sources**, selects **Manage** beside **Google Drive**, and opens the source's **Settings** tab. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. +An admin opens **Settings → Sources**, selects **Manage** beside **Google Drive**, opens **Advanced**, and selects the sync configuration's **Settings** tab. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. Keep **Sync documents with → Connected members** unless a dedicated account should fetch content. Members still connect to establish access. If the dedicated account is a delegated service account, **Crawl as** selects the Google Workspace user whose files it fetches. Save the source settings when finished. @@ -52,7 +52,7 @@ Keep **Sync documents with → Connected members** unless a dedicated account sh ## Set up a central service account -Open **Settings → Sources** and turn on **Google Drive** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. This opens central service-account setup. Teammates do not need a personal Drive connection for this source. +Open **Settings → Sources** and turn on **Google Drive**. Select **Manage → Advanced → Add sync configuration**. This opens central service-account setup. If your organization has only central indexing enabled, select **Add source** from the provider's **Sources** tab instead. Teammates do not need a personal Drive connection for this source. This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. @@ -90,6 +90,8 @@ Select **Authorize**, then **View details** to confirm all three scopes were sav These are Search's central crawl scopes. The general [Google service account guide](/integrations/google-service-account) includes broader scopes for workflow actions; do not copy those into this Search setup. +Group permissions require groups and memberships that the indexing administrator can read in this Google Workspace customer. External groups and unresolvable nested groups are not supported. Google Drive target-audience shares are not mapped; use explicit user, supported group, or domain permissions instead. + @@ -121,14 +123,13 @@ Set **Crawl as** to a Google Workspace administrator who can read groups, member Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads). -Search schedules syncs hourly. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. Open **Settings → Sources**, select **Manage** beside **Google Drive**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. **Accounts** on the provider page shows personal account connections where configured; it does not list the central service-account credential. +Search schedules syncs hourly. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. Open **Settings → Sources**, select **Manage** beside **Google Drive**, then open its configuration under **Advanced** to inspect **Documents**, edit **Settings**, or review **Sync history**. **Accounts** on the provider page shows personal account connections where configured; it does not list the central service-account credential. ## Troubleshooting | Problem | Next step | | --- | --- | | Directory access failed | Check the delegated scopes and the **Crawl as** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. | -| An existing central source uses a normal Google OAuth account | Open the source's **Settings** tab, select or add a delegated service account, and choose **Change indexing account**. If the source is **Paused** or **Disabled**, choose **Resume** after updating the account. | | Missing files in a central crawl | Open them as the **Crawl as** user. Delegation does not grant that user access to all domain files. Check folder and file-type filters. | | A teammate sees no results | Confirm their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. | | A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. | @@ -150,7 +151,7 @@ https:///api/auth/oauth2/callback/google-drive This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. -Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. The current Sim Drive OAuth connection uses these scopes: diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index cfbd2812709..6119f99440b 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -16,45 +16,47 @@ Search brings your connected sources into one place. An organization admin allow ### Allow an integration -As an organization admin, open **Settings → Sources** and turn on the integration under **Allowed in Sim Search**. It stays in the list; no setup page opens automatically. +As an organization admin, open **Settings → Sources** and turn on the integration. It stays in the list; no setup page opens automatically. The switch permits the integration in your organization. It does not connect an account, grant document access, or start indexing. Teammates connect from **Integrations** in the main sidebar; only admins manage these switches. -### Configure it once +### Connect or configure the source -Select **Set up** beside the integration, or **Manage** if it already has sources. On its page, select **Add source**. For Slack, complete **Set up Slack app** first. Follow its **Setup guide** to connect the indexing account or choose the folders, repositories, calendars, spaces, or channels to include. **More options** contains secondary filters and **Metadata tags**. +For personal Gmail, Calendar, or Drive, teammates can connect immediately from **Integrations**. The first account creates the default sync configuration. Admins can adjust it later under **Manage → Advanced** on the provider page. -Select **Connect & Sync** for an administrator connection, or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. +For other sources, select **Set up** or **Manage**, then **Add source**. For a central Drive service account, use **Manage → Advanced → Add sync configuration**. For Slack, complete **Set up Slack app** first. Follow the provider's **Setup guide** to choose the indexing account and content. **More options** contains secondary filters and **Metadata tags**. + +In the source form, select **Connect & Sync** for a central account or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. ### Connect and search -Open **Integrations** in the main sidebar and select **Connect account** if prompted—even if you created the source. Complete authorization in the new tab. Open **Search** to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. +Open **Integrations** in the main sidebar and select **Connect** if prompted—even if you created the source. Complete authorization in the new tab. Open **Search** to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. -Organization Sources settings with Allowed in Sim Search switches and separate Set up and Manage actions +Organization Sources settings with provider switches, Set up or Manage actions, and Update configurations -Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. +Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. If an existing provider asks for a configuration update, an admin selects **Update configurations** on this page, then affected teammates reconnect. ## Choose the right connection method -Most sources use member accounts. GitHub supports a central App installation with a personal connection for each reader. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. +Most sources use member accounts. GitHub supports a central App installation with a personal connection for each reader. Google Drive and Confluence also support a central service-account connection; GitLab requires an administrator token for a self-managed instance. | Method | What the admin does | What teammates do | | --- | --- | --- | -| **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. | +| **Member accounts** | Allows the provider and adjusts shared filters when needed. | Connect their own accounts. Sim lists documents using each member's access. | | **GitHub App installation** | Installs the App, selects it under **Sync documents with**, and adds repository sources. | Connect GitHub once. Sim checks each reader's current repository access before returning installation-indexed content. | -| **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | +| **Service account** (Drive and Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | | **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | -Adding a Google Drive or Confluence source from the admin page starts central setup. For personal connections, use **Integrations → Connect account** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. +Adding a Google Drive or Confluence source from the admin page starts central setup. Drive uses **Manage → Advanced → Add sync configuration**; Confluence uses **Set up/Manage → Add source**. For personal connections, use **Integrations → Connect** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. When personal connections are disabled for the organization, central Drive uses **Sources → Add source** instead of **Advanced**. Some member sources offer **Sync documents with**, either directly in setup or under **More options**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. For GitHub organization sources, choose **Connect GitHub App** in this field to [connect an installation](/search/github#add-a-repository). **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. @@ -66,7 +68,7 @@ Some member sources offer **Sync documents with**, either directly in setup or u | Source | Content | Connection in Search | | --- | --- | --- | -| [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects | +| [Confluence](/search/confluence) | Pages and blog posts | Service account or member accounts; each teammate connects | | [GitHub](/search/github) | Repository text files | App installation or member indexing; each teammate connects | | [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection | | [Gmail](/search/gmail) | Email thread text | Each member's Gmail account | @@ -89,39 +91,31 @@ These requests are separate from organization invitations. They let recipients c ## Manage sources and documents -Open **Settings → Sources**, select **Manage** beside the integration, then select a source. Providers with personal connections have **Sources** and **Accounts** tabs; providers such as GitLab open directly to the source list. Multiple sources can have different folder, repository, space, or project scopes. +Open **Settings → Sources** and select **Manage** beside the integration. Gmail, Calendar, and Drive list their sync configurations under **Advanced** and personal connections under **Accounts**. Other providers with personal connections use **Sources** and **Accounts**; GitLab opens directly to the source list. Select a source or sync configuration to manage it. Multiple sources can have different folder, repository, space, or project scopes. + +For Gmail, one configuration is usually enough. **Add sync configuration** creates another source; editing **Settings** updates the selected source. Each configuration applies to all connected Gmail accounts, including accounts connected later, using each person's own mailbox permissions. Configurations are not assigned to individual people. -Integration detail with Sources and Accounts tabs and a nested source list +Gmail Advanced tab with a sync configuration, search, and Add sync configuration action | Source tab | What you can do | | --- | --- | | **Documents** | Find indexed documents, inspect processing status, retry failed indexing, or exclude and restore documents. | -| **Settings** | Edit the source's scope, filters, and supported connection settings. Save your changes before leaving. | -| **Sync history** | Review recent sync runs and errors. | +| **Settings** | Edit the source's scope, filters, and supported indexing credentials. Save your changes before leaving. | +| **Sync history** | Review run dates, document changes, and any sync or account errors. | -Use the source header to sync, pause, resume, or remove that source. The back link returns to its integration. To disable an entire integration, turn off its **Allowed in Sim Search** switch. If it has sources, confirm **Deactivate**. Its content becomes unavailable in Search, Assistant, and MCP; sources and connected accounts are preserved. Turn the switch back on to allow it again. +**Sync using** shows the method selected when the source was created. Create a new source to change that method. To replace a supported indexing credential, select its replacement and use **Change indexing account**. -Gmail source Settings tab with label, date range, and search filters +Use the source header to sync, pause, resume, or remove that source. The back link returns to its integration. To disable an entire integration, turn off its switch in **Settings → Sources**. If it has sources, confirm **Deactivate**. Its content becomes unavailable in Search, Assistant, and MCP; sources and connected accounts are preserved. Turn the switch back on to allow it again. -Source Sync history with no connected accounts or synced members +Gmail Settings with the read-only Sync using method and editable label, date range, and search filters + +Gmail Sync history showing run dates and document additions, deletions, or no changes ## Search, Assistant, and MCP **Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin. -To search from Claude, Codex, Claude Code, or Cursor, open **Settings → Search MCP**, choose your app, and copy its URL, command, or configuration. Connect in that app, sign in to Sim, and approve read-only Search access. No API key is needed. In Claude Team or Enterprise, an owner adds the custom connector before members connect. - -For another client, choose **Other** and use the server URL with Streamable HTTP and OAuth. The client must support remote MCP authentication. When adding configuration to an existing file, keep your other MCP servers. - -Each person signs in with their own Sim account. MCP applies their current organization membership and document access; connecting an app does not add sources or grant new document permissions. To disconnect an app, open **Settings → General → Authorized apps** and revoke it. - -MCP provides three tools for your organization: - -- **search** finds indexed passages. Narrow results by source, modification date, or document. -- **read_document** opens an indexed document by ID or its original URL. Read around a matching passage or page through longer documents. Results include a citation link. -- **chat** asks the Sim Assistant for an answer with citations. Each call starts a new private conversation and can use the same search filters. - -Search MCP is available in organization settings. All three tools use the caller’s current document permissions. They do not browse the web or change connected sources. +To use these sources from Claude, Codex, Claude Code, Cursor, or another compatible app, open **Settings → Search MCP**. Each person signs in with their own Sim account. The server provides `search`, `read_document`, and `chat`; `chat` starts a new private Sim conversation. See [Search MCP](/search/mcp) for app setup, permissions, and limits. ## Existing workspace Search @@ -138,9 +132,9 @@ Search runs background syncs on an hourly schedule. Large sources, provider limi ## If indexing needs attention -A completed sync means the source was checked; some documents may still be indexing. In the main **Integrations** page, each source row shows how many documents you can search and whether indexing failed for any documents you can access. +A completed sync means the source was checked; some documents may still be indexing. Integrations lists personal connections; a central source that needs no personal account, such as delegated Drive, can be searchable without appearing there. In the main **Integrations** page, each connected source row shows how many documents you can search and whether indexing failed for any documents you can access. -As an admin, open **Settings → Sources**, select **Manage** beside the integration, then open the source. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. +As an admin, open **Settings → Sources**, select **Manage** beside the integration, then open the source from **Advanced** (Google providers) or **Sources**. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. Empty source Documents tab with search and an Included status filter diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index 4c0b5fa4df6..ea3e1e960c1 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -32,7 +32,7 @@ Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to re ### Choose Jira -Open **Settings → Sources** and turn on **Jira** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Jira sources use member accounts. +Open **Settings → Sources** and turn on **Jira**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Jira sources use member accounts. @@ -66,7 +66,7 @@ Click **Add source**. The source appears on Jira's **Sources** tab. Creating it ### Connect your search account -Open **Integrations** in the main sidebar and click **Connect account** on the Jira source. Complete the connection in the new tab using the Atlassian email that matches your verified Sim email. Select the configured Atlassian site when asked and grant the requested permissions. +Open **Integrations** in the main sidebar and click **Connect** on the Jira source. Complete the connection in the new tab using the Atlassian email that matches your verified Sim email. Select the configured Atlassian site when asked and grant the requested permissions. Return to Integrations to see connection and indexing status. Each teammate follows this same step. A previously authorized account may already be connected. @@ -86,11 +86,11 @@ Search manages the sync schedule. Item limits and sync frequency are not setup d ## Teammates and ongoing sync -Existing organization members see the same source configuration and their own **Connect account**, **Reconnect**, or indexing status. They do not choose projects again. Invite new teammates to the Sim organization through its Members settings or SSO onboarding, then have them open Integrations and connect Jira. A Jira authorization does not grant Sim organization membership. +Existing organization members see the same source configuration and their own **Connect**, **Reconnect**, or indexing status. They do not choose projects again. Invite new teammates to the Sim organization through its Members settings or SSO onboarding, then have them open Integrations and connect Jira. A Jira authorization does not grant Sim organization membership. Sim checks Jira separately using each connected person's account. Issue content and tags become searchable as processing finishes; changes and lost issue access are picked up by later syncs. The main Integrations page reports documents searchable by the current viewer. Admins open **Settings → Sources**, select **Manage** beside **Jira**, then open the source for **Documents**, **Settings**, and **Sync history**. -Use **Jira → Accounts → Request connections** to send Jira connection requests. These requests do not invite people into the Sim organization. For a different site or project scope, an admin can add another source; members can also use **Add another Jira source** in the main Integrations page. +Use **Jira → Accounts → Request connections** to send Jira connection requests. These requests do not invite people into the Sim organization. For a different site or project scope, an admin can add another source; members can also select **Connect** beside the Jira row labeled **Connect a different site or content scope** in Integrations. ## Troubleshooting @@ -99,10 +99,10 @@ Use **Jira → Accounts → Request connections** to send Jira connection reques | No provider setup controls | Ask a Sim organization admin to approve and set up Jira. | | Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. | | Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. | -| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. | +| Email mismatch | Sign in to Atlassian with the same email as your verified Sim account. If switching accounts in Jira does not help, log out of Atlassian and sign in again before retrying **Connect**. | | Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. | | **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. | -| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. | +| Connection tab does not open | Allow pop-ups for Sim, then click **Connect** again. | ### Check access in Jira @@ -122,7 +122,7 @@ Atlassian illustration from its [permissions tutorial](https://www.atlassian.com ## Self-hosted operator setup -The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect account** from Sim. +The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect** from Sim. 1. Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/) and select your deployment's **OAuth 2.0 integration**, or create one for the deployment. 2. Under **Authorization**, configure **OAuth 2.0 (3LO)**. Add `https:///api/auth/oauth2/callback/jira` to **Callback URLs**, keeping any callbacks already used by your deployment, then save. @@ -138,6 +138,6 @@ The deployment operator configures one shared Jira OAuth integration. Teammates 3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions. 4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start a connection from **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing requested scopes, reconnect previously authorized accounts. +5. Start a connection from **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing the deployment's OAuth client or requested scopes, an organization admin selects **Settings → Sources → Update configurations**, then affected teammates reconnect. For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from **Integrations**. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/mcp.mdx b/apps/docs/content/docs/search/mcp.mdx new file mode 100644 index 00000000000..bd2d3de0638 --- /dev/null +++ b/apps/docs/content/docs/search/mcp.mdx @@ -0,0 +1,52 @@ +--- +title: Search MCP +description: Search your organization's sources from Claude, Codex, Cursor, and other MCP apps +--- + +Use Sim Search from another app to find and read your organization's indexed documents, or ask the Sim Assistant for cited answers. Your Sim permissions apply. + +## Connect an app + +1. Confirm you can find a document in the organization's **Search**. [Connect your source account](/search/connect-your-account) first if required. +2. Open **Settings → Search MCP** in the organization view. Select your **App** and copy the displayed URL, command, or configuration. +3. Follow the steps for your app below, sign in to Sim, and approve Search access. Each teammate signs in separately; no API key is needed for this flow. + +| App | Finish setup | +| --- | --- | +| **Claude** | Add the copied **Server URL** as a custom connector, then connect it. For Team or Enterprise, an owner first adds it under **Organization settings → Connectors**; members then connect individually. See [Claude's custom connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). | +| **Codex** | Run the copied **Terminal command** and complete browser sign-in. If authentication is needed again, run `codex mcp login sim-search`. | +| **Claude Code** | Run the copied **Terminal command**, then open `/mcp` in Claude Code and authenticate. See [Claude Code's MCP instructions](https://code.claude.com/docs/en/mcp). | +| **Cursor** | Add the copied `sim-search` entry to `mcpServers` in `~/.cursor/mcp.json`, preserving your other entries. Enable the server in Cursor and sign in to Sim. See [Cursor's MCP instructions](https://cursor.com/docs/mcp). | +| **Other** | Add the **Server URL** to a client that supports remote MCP with OAuth. Choose **Streamable HTTP** if asked. | + +For Claude's hosted custom connectors, your Sim deployment must be reachable from Claude's servers; a localhost URL will not work. See [Claude's network requirements](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). + +## Use the tools + +Ask your app: “Use Sim Search to find our launch checklist, read the relevant document, and cite the source.” The app can use three tools: + +| Tool | Behavior | +| --- | --- | +| `search` | Finds matching passages. Filter by `source` (such as `jira`), `modifiedAfter` (an ISO timestamp), or `documentIds`. Use the returned `citationUrl` when citing a result. | +| `read_document` | Reads an indexed document using either its returned `documentId` or original URL. Use `aroundChunkIndex` for context around a search hit, or `offset` to page through it. It does not fetch arbitrary web pages. | +| `chat` | Asks the Sim Assistant a question using your accessible sources and returns an answer with citations. Each call creates a new private Sim conversation. It accepts the same filters as `search`. | + +The tools do not edit source content. Assistant policy and usage limits apply to `chat`. + +## Access and limits + +The connection applies to the organization whose URL you copied. Sim checks your current membership and source access on each request. Connecting MCP does not add sources, connect provider accounts, or grant additional document permissions. Source edits and permission changes follow the same sync behavior as regular Search. + +Search returns 10 passages by default, with `topK` up to 50. Document reads return 20 chunks by default, with `limit` up to 50. When `pagination.hasMore` is true, continue at `pagination.offset + pagination.limit`; use either `offset` or `aroundChunkIndex`, not both. Documents still indexing return metadata only. + +Queries allow up to 8,192 characters; `documentIds` accepts up to 20 IDs. Responses are limited to 1 MiB. Request fewer passages or smaller document pages if a result is too large. API rate limits also apply; follow the retry delay returned by the tool. + +## Reconnect or revoke access + +If sign-in expires or access is revoked, reconnect in your app. To withdraw its Sim authorization, open **Settings → General → Authorized apps**, find the app, and revoke it. This stops future requests; it does not remove content already returned to that app. + +If Search MCP is unavailable, ask an organization admin to check the organization's Search availability and MCP policy. If results are missing, check the same query in Sim Search, your provider connection, and source sync status first. + +## Workspace MCP is separate + +Organization Search MCP searches organization sources. [Workspace MCP tools](/agents/mcp) connect external servers to Sim agents; [MCP deployment](/workflows/deployment/mcp) exposes workflows as tools. Neither setup automatically adds workspace content to organization Search. diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index f3f4dffbd2c..473704f2d45 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -2,6 +2,7 @@ "title": "Search", "pages": [ "connect-your-account", + "mcp", "confluence", "github", "gitlab", diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 81d8c0cb91f..42917bc7438 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -6,15 +6,13 @@ description: Set up a workspace Slack app and connect members for channel search import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Slack Search indexes channel messages and threads. A Sim organization admin configures your Slack app once, then each teammate authorizes their own Slack account. Their results are limited to the selected public channels and private channels they can access. DMs and group DMs are not indexed. - -Slack app setup for Sim Search with application and client credential fields +Slack Search indexes messages and threads each connected member can access. Public and private channels are included by default; one-to-one and group DMs are opt-in. A Sim organization admin installs the organization's Slack app, then each teammate authorizes their own account for indexing. ## Before you start You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account. -This guide covers **indexing Slack messages for Search and MCP**. It does not install a Sim assistant that answers inside Slack. +The same app supports **Sim Search in Slack** and **indexing Slack messages**. Installing the bot lets it answer questions about connected sources. Indexing Slack content additionally requires a source and each member’s authorization. ## Set up the organization's Slack app @@ -25,54 +23,57 @@ Skip to **Connect the source** if the organization already has a verified Slack ### Open provider settings -Open **Settings → Sources** and turn on **Slack** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Set up Slack app**. Allowing Slack does not configure the app or start indexing. +Open **Settings → Sources** and turn on **Slack**. Select **Set up** (or **Manage** if sources already exist), then **Set up Slack app**. Allowing Slack does not configure the app or start indexing. ### Configure an app in Slack -On the [Slack Apps page](https://api.slack.com/apps), create an app for the target workspace, or use an app dedicated to your Sim organization. Under **OAuth & Permissions**, add the user scopes and both redirect URLs listed below. Keep **Token Rotation** disabled. +Select **Install Sim Search** to open the three-step setup. In step 1, select **Create app in Slack** and choose the target workspace. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled. + +You can also open this wizard from **Settings → Sim Search in Slack → Set up**. Slack app settings showing Basic Information and App Credentials *Official Slack example: [Basic Information](https://docs.slack.dev/tools/bolt-python/creating-an-app/#create-a-new-app). Use your own app's credentials.* -In Sim, enter these four fields: +Return to Sim and select **Continue**. In step 2, copy **Client ID**, **Client Secret**, and **Signing Secret** from the new app’s **Basic Information → App Credentials**: -| Sim field | Where to find it | -|---|---| -| Slack App ID | Slack app **Basic Information → App Credentials** (`A…`). | -| Slack workspace ID | The workspace segment of the Slack web URL, `app.slack.com/client/T…/…`. | -| Client ID | The same app's **Basic Information → App Credentials**. | -| Client Secret | The same app's **Basic Information → App Credentials**. | +Sim Search in Slack setup asking for Client ID, Client Secret, and Signing Secret -Organization setup uses personal user authorization. It does not ask for a bot token or signing secret. +Select **Continue**, then **Install in Slack** in step 3. Approve the installation in Slack. Sim saves the bot connection and opens **Settings → Sim Search in Slack**. Complete any required Slack administrator approval before continuing. ### Verify and continue -Select **Verify and add**, then authorize the app in the Slack popup. Sim verifies the app, workspace, client credentials, and required scopes. Allow popups if the window does not open. +Return to **Settings → Sources → Slack → Set up Slack app**. Select **Verify and add** and authorize member access for the installed app. Allow popups if the authorization window does not open. -After verification, Sim returns to the provider page. Open **Sources → Add source** to choose what to index. If you opened app setup from an unfinished source form, Sim returns to that form instead. If Slack requires administrator approval, complete that approval before continuing. +Member connections use one configured Slack app and workspace per organization. Installing another bot does not change that configuration. Changing the verified app requires members to reconnect. + +After verification, open **Sources → Add source** on Slack’s provider page to choose what to index. A connected bot alone does not mean Slack messages have been indexed. ## Connect the source -Open **Sources → Add source** on Slack's provider page. **Channels** and **Earliest Message Date** appear first. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. +Open **Sources → Add source** on Slack's provider page. Choose **Channel Messages**, **Direct Messages**, **Channels**, and **Earliest Message Date**. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. | Field | Behavior | |---|---| +| Channel Messages | Included by default. Turn off for a DM-only source. | +| Direct Messages | Excluded by default. Include to index one-to-one and group DMs the connected member can access. | | Channels | Leave blank for all accessible public and private channels, or choose channel names/IDs. | | Excluded Channels | Names or IDs to omit; exclusions override included channels. | -| Archived Channels | Included by default. | +| Archived Channels | Included by default. The picker lists active channels; use manual names/IDs to select archived channels. | | Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. | -Select **Add source**. Each person opens **Integrations** in the main sidebar, selects **Connect account** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. +Select **Add source**. Each person opens **Integrations** in the main sidebar, selects **Connect** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. + +The Slack app's **Home → Connect sources** opens this same Integrations page. To send a Slack connection request, open **Slack → Accounts → Request connections**. This requests an external account connection; it does not invite the recipient to the Sim organization. @@ -82,27 +83,28 @@ Admins open **Settings → Sources**, select **Manage** beside **Slack**, then o ## Permissions reference -The organization account pool supports Search and workspace workflow tools. Its current authorization requests the following **User Token Scopes**, including write permissions. Search itself only indexes channel messages and threads; workspace use is controlled separately in the organization account settings. +New Search member connections request these read-only **User Token Scopes**. They are separate from the bot scopes used to answer messages in Slack. | Purpose | User scopes | |---|---| -| Public channels | `channels:read`, `channels:history`, `channels:write` | -| Private channels | `groups:read`, `groups:history`, `groups:write` | -| Messages and conversations | `chat:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write` | -| Files and canvases | `files:read`, `files:write`, `canvases:read`, `canvases:write` | -| Reactions | `reactions:read`, `reactions:write` | -| Identity and profile | `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write` | +| Public channels | `channels:read`, `channels:history` | +| Private channels | `groups:read`, `groups:history` | +| Direct messages | `im:read`, `im:history`, `mpim:read`, `mpim:history` | +| Identity | `users:read`, `users:read.email` | -Add both redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: +The generated manifest includes these redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: ```text +https:///api/knowledge/slack/oauth/callback https:///api/credential-groups/slack-managed-users/callback https:///api/credential-groups/oauth/slack/callback ``` Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table above. If scopes change, update the Slack app and have members reconnect. Do not change a shared production app's credentials to configure a separate test installation. -For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests the six read-only channel and identity scopes instead. Its bot installation is separate from the organization setup described here. +Existing workflow account pools retain their configured permissions; preserve those scopes when updating the shared app. + +For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests read-only channel, DM, and identity scopes. Its bot installation is separate from the organization setup described here. See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/). @@ -111,7 +113,7 @@ See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manife | Problem | Next step | |---|---| | Setup keeps asking for a Slack app | Finish **Verify and add** in the Slack setup; approval alone is insufficient. | -| Redirect mismatch | Check both redirect URLs above against your Sim origin. | +| Redirect mismatch | Check all three redirect URLs above against your Sim origin. | | App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. | | Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. | | Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. | diff --git a/apps/docs/public/static/search/confluence-setup.jpg b/apps/docs/public/static/search/confluence-setup.jpg index 8ad1f1aed9262fd50bea14c3e478991ced96e1e5..8efc63e456d7d6f289e9c7401b1668eca8351de4 100644 GIT binary patch literal 24808 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 literal 35214 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 diff --git a/apps/docs/public/static/search/connect-account.png b/apps/docs/public/static/search/connect-account.png index 163bdd34312829e1835b6301ab694a94231865d5..d4ad8a4044ab79549ed5298f3658438c18aa1493 100644 GIT binary patch literal 100713 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ɡ-0Vd>bG;6c?4`5IiYSoL-g) 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
0M--$uxH{2+N`iOK9JPUBTeMqUn4 zFTn`FN^Bik(*42uIBLurgE+d@%i?=nbmu5kKYJXAw>kA-qt&ZW(Wu(nYAFs_OMBiX zlN1&pH^P2M6Hx!e_4G#bngTC3C$j2@(M3KKd=lp6Enfug%@fYX?rk6~ zhvG1r@bJ?h4qFS@h-Idw3p)|Fb|u2mIP*omDSc%}+Q73@b>NzQ|ID_sO&~51Dbma6&zOh)nW8*TMQIY0`1{UZr zSm^w-VQ`fPZ=${Xp>3LjhT|dSx?V~voPbSOY!q(mC>uqHy@E1}egu<0Ih$}g*}bMu zp(E5Ph>uOaB6O=sR|?va3OJuv1#BFQ)KOq%0y;rnKc1N~XSe>HKN+Y{%eQEHzWU|M zaXzoL2XAN#8eYn4qvi>7i16L}dCR_wFFOP!sVPG0mPl0=6;ac3F?Vpdb>9bxp9t8% zL-63emZN-w!^%-fg2t#B1U`vQ*pW&9VuK?IA$3ciq#Q?|%qLXj*EAgdAiv>PSxunj zDrHjLM3Eawq2-C^$T5ly?`(Ln7y8d0_ zdQ`G1{nRh#Mew_iD(q8n!4TtC3a_lC;+<=aA3c^or*6|*fq zui<{#HUL?@R1FHY0#XOxF3W6)Mez>&rojcB0YukiRWV>w&^whYaHQ}!Fl{~YYtlX> zEyX16kaaqpsCvA~Iw_K{^2zmX23}SBvyGdj_SKC;8?A_R2dSUA*+oGrkwTwMi4gt@ zN^7S>tWtzWI807u8MNc7kxghwrB1_3G1Vwv69KzIX4Jy=C`sXF<7Jz? zC%0P!-3%YIP1ZFnCYt}}Z_oSog#Dc2Cm=o@!=Bb{`JrGEiMx)V!@*a_GA(3#zZ; zY5lKI_QGw3GRF?F5`xbXEnI!Amg;jK3{o3Awi6yGKPd;k*|h!iM01$^3$c3P5{F2c zxmOH53|sMGY0`bh?z2`6)KJv}!H>1*-O{OAI7huer|qH{GZAD>c!U?XCSG9PZM%-~ zh&hkA&A>|O;Ahcq2`Wl?ss$Zu;YgFSG6gW@;WGIjs`tNI8$Z#rJDn8bSY*xg<5KMX z2df866w!k(>n!v)Ob%uqU5m>!>iz3U@q`jBZV(M!(L?h`9Epj5=o^S+M}|CTYjz7-D^8 zqy9Qp3fp<_A!BK8fY`hh8!#V@euwcBX1Tp+m7Dy2Rhw^5rZtj67#%k4l7f$55W}n{ z3W9*`JjX{#wJfYGd6K!{7~t--O@n=8h6XYPw3(S(myA#UudrUo92C?k^1-2K^by67 zwyP(QBv2ChU*unB|%XDv? z2IG^P({Cx$Xe^`WJCnzH9V=h%C;-Ai+* z6DqP9UMeFsvN8NZse4JfJ87%zf|xG)fKPAEU!F(PWiTcEOt5aHp3cA9xXoe=)b7n` z@o_^M&)hMTfGw~^cU!n-gRJe@OZT9`Un1|$>P<}t73U5I(Yk5w0G#iI1arHYClGaq z&3+muJro$TcqI92WstwK=Px+1o8#k9aN+>B*froCfr=`V9uk@%)0u@)9D9BqF8f&2 zME4-9?6*=BxsGKqBIrA19H1ZTV2lk49ghg_!Kbxa3EhN6+^=A5bz+P>s@_DA<`<#h z0XC*T;-~Z5F{3pD=Q6q%vCLi&E^MU5vrodR*VtHU=Ohq6;LIQFdw(WXZ{oN%0D0by_&bKwb-+|b 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 literal 153886 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


b(_rFRz0861s;A)#>At$ptUlL$-_-hD*z*O; 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

rv<#%X!=Y5b{)%#WfqMKcOByBiI57sT}T?K$eU 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 Lakj3p3`qD70#;F0 diff --git a/apps/docs/public/static/search/integration-provider.jpg b/apps/docs/public/static/search/integration-provider.jpg index 96341327613d9e536f260924778075db657daeaa..bf655343f517151e7bc2e65f75dad1a64786bf82 100644 GIT binary patch literal 21510 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 literal 14091 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 literal 115160 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 y9Zkoy4a*Jcnp>dQQumc>2 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%0D{fc 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Πyr%W(9V@A>2# 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 diff --git a/apps/docs/public/static/search/slack-setup.jpg b/apps/docs/public/static/search/slack-setup.jpg index 92e06d28802a1eeebf5877300ce9028b90ec212f..c9ee55a27df97705a2a5925ea225a03a3c9b03ef 100644 GIT binary patch literal 39338 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 Z60O}S$zFSejpOV1R|JFgg<;#h{{d|TXwLuu literal 46590 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 diff --git a/apps/docs/public/static/search/source-settings.jpg b/apps/docs/public/static/search/source-settings.jpg index 1a1cb850924a4b88744107e35bcafa060b756e01..d89c80623807b52f49e4bd2119f4c6b08cfc04ce 100644 GIT binary patch literal 37543 zcmeFa30zZG*Drh!Ep?;{NEL-vK~$!v3Zx1Wts)?$5fzXjPzPitqRb>5>x4j&0s<8Z zLFOqSLkL4is!ReRGD8B1%u_-jKoUrD@*Vor*7tr--}~M7e!u(c^Ii{}kdSls*?XV0 z*Z%Ld{%ajczvLb8*}1c3X8|cG05FAr0Ld`$t#P2+4FIsP01g8H@F^e_<_4^X-@%6f zyWt%Gq|>ATY51?y+W*qlz5iDF>on>2@7Fo59VlS~U!Qly_+oCmVs3tMRQn&m*WZ{~ ztY0e)-hO+p_S@Ufy7&G#5Dk>BpT4v4XO`fbP|2^r_D|P2N`EaawGUXgT}pbpl%yU| zfv>qi>bLE;)8H4Wb<*oMd?NGd#!Z{y1Bjmi>!hTm*R7Y{uwm^oQaJc~VEy(DJHGtt z)F*P5S7i42$RGJ3=GmwFzbSd4aG_&f_2|`GcQ-D(Ha4<0(KtEZ3n`q=T) zXN=F9n3|otc*)Az=CZBbwd>9|TwLAUZ~OZB2LuKM-@E_dVc4Vah}gI%@d=4PKTS%{ z$jr*l$<50zEh{HhkSeRHU)I$(G&a3zZt3jm?&@OioSDEN~Z>mU(=^ z%IaFX;Qst)v*6c%w(Ngs*LJvF>(;NAUN5uOE~#|^YYpGNe#4hveX`?}rOXu{xqU}| z_*DLzm}ey~Hts)qVP4_tt&UAQRdt8e7S@{fTg(2xHSF$xsbzmO?9X=5fz!ac-!|!W z>!deGOG|I~WCOf?^64jQ+ozj8{cYRy`?lq`ZQI%=`^P4M3z34$SigR~4E+1q=8c;_ z`(NH9L$FxtN@&0qX(?Ekq_+b|K;)7haS%w4V2#epL}bmMXsUO59{<*BF1De_wqmX_ z2Ejf99S24a7PX`1RUY~2te;^ZiI0)26y*^{@9&BT#=iyQexKYhp!1W5rBZ zndGt}0S2v$jP7hYn0{xI#`A@MyLz1UW*OP!k^+xluWW+HIECGt7+H&u06vgY)mnL$ zHk|Sl8}rA#UgzF~e9gJGNT6L#QcKd|WJ>@FUWb{sN#63CB3}O!z!HslOx|9MJ+^S; z*}HZ*O`jRNYx`c8Ad{$ME8n#%I~Se%=atv4{x{!|lDGf7bH!!ahV~=iPN*rkoN=&6 zThCULZ0Uya?$hRyeN5bI6p;PJKh9E#f+zw_bd)Txk=K_11BmKa_t6{<7fqFxtg@ zq3=EswN0R79DQb9JTxBlH~|75Ev{*cdyG0?wfSsqxW@@|X)ohQ0Gx8I<1DXu!LVeuJtxqlF3Fi6!S>c#Hm7?W zvkr+a*Vs>y00v`VGAM-qzLj~7y&%X*#~RIZUfLT(OHDD}hx@HL)?y#wotHw2cA*!-w9+>y{{5_T@xtb9SP#;sSUAe%3=+h@ut zn>!f|cTZoNK9#8R$o}*^bgwuNSr@!aeZXR~mU_E2vr>kI#}8welQ=$&auwGeA9`-w z7o@3->`WPz-5yA~Bs@?n0XDj424_8fCX?*!8zTYoY$EyK400ULKPb(nhE=F+E28LN z4=rqq@)>SeaG~khqrcJFTv&410>j(L*oDn8LRUTS!#Wy zaeLSWGrJLellXupIYWaEqW}vbww-4r0h-aJP`9Ig>4N%0KdP+js!?Q)z^rJjYbn`0 z=<4%D?-AD<36~a0H7pDjzB%9EAmlt}L^j+=QBd06sH_;PMYk@ff;SY%m`uF7Wc`b=<71DVFTm&4U8(i$as;GbJ~Tb8ydM|5 z#HrEQf{SLIE`$Aa6`a$84EyK^&c(b>tDL%J zN?Ezv&~h_1mjDTPW|}O4jomzq_9@5j5u~5c2L)#+N&+{+ z)m~d_8Kom&BHcWoL^U8>U)+XokO0y}N*Cr;UQ)wJj?!Hi9YZaz5p(8eYEdHs^R~M! zqu+9qTo{jISe1+#M2=|5i7z)HM(`dlOe7NAs_U(*eVGEFxFvW%J?p_JU~vo9wJsS`(4H<|ri@##0@po(N7JMJMD>cE`)-c2zb0oeP4?1) zOg%?xCMoep!z~hElW)JS_^<)7bF!1AKcint(|_emNd`GrS3rIc+IPb!$<&=2$ar^c3u+YyFRKLXXos3$0{$E;UVeM zJ%;9MomYf+9(NitR$m!H7W!#qvC5LVNrTI${ufDeDW;6R5ymMp=mx||6KHlvtQf*iG6edOJn(ehh0k2 z@qTWf^@TNv!;o9a42j=cZAGZ_dQddflMO-jXJU9D>Exbx1yYeK0#dGiOk2FBS+ep| ztDHBi0=Orxo|iIK2z^t>wkFviT_5U{vBZPX6}E;=16=imw;MKT*w}R*=+N^ZC{M>^ zm&di0DbpAfE+AOHuo6!$d>`NmipYI$gArYT`5AE`z^P8cDAY-@b z>WPvJZ>X7)+66MWS*|3IDK9mruWuc^<);~I>r(o+tJ=?ec;Cj3cf2TSHw6eIw(^p4RFR_A}9MF zEz3q|KKlWCes*JonU`-~ru7-?#q03_oHHxiCBS4>Ad&h=blSs5Z@ehWPod#udh@3-j)Di3$Q3UbJabAH2d4UF)(%0&~N zr&00A&!1Q`*WUaWK5?gS)_9^>JtUR8Mb2o}OC6pqRk#Tl&V{8F`pg5r!ou@my?Vt& z^hFn@R(s$Bp4B9tZxTyRx;EW=-^hAr3WeeVZqNYGvd(-j1 z`zds$jolZFZN2ojkpO8)&p+>CQEZRDeG|mK?bk(z6#^NY%L06lW5V>J?0SkdN6$V` zz-} zD=o6^+PO|_`#!573815L0G`mZZ&rw(NR4*e5a|83gZA{<>jyk+_E=qpXPJt1+YT zmTFw;;0+v})$9K2YF54nxKZU{wXhde&MePx#fc&7xuQ@GJMYf+tC=cXO)Tw~85eOuxM<2QnAv$$Qb0SMZv zz*==oPNUJLc(>@{62DK;nO0?s<4^ge$1`gnln!RzYOCH-e~}>~-6+|^F!+}cZ?#w* z2I;BKVH|t`kT)gWLSr~TK9+iBAnJ5kGPJ|AaN_RP-3NE}YD8|70HHsnht6E03#&p` zZNP*R??WD>t@-{P! z#Cc5JZrMo67w(RG=HEQ&tw>ev`ZCJTLL2SQFgT=T&E;+m3A~ar0y1OUWr|MUXCKE$6S|g8L(oEnQdgjw9ff9gTe4raS_NAW$K&wcA(`j2nR5s1h z;tK;3te<5hf{9fzJ3-z0i1)65#2Cj(G8;vXyZ#d>?PL2DuD# zSahq0qh@BAi7jGIGSA5XhWqb?#P7H|rG>#KQX{vH7fet4OtR@3K{1#h5$7G3DL@Vk zoK*JP8~?iEwo^VdF%)y%i|IEuR3-sVx$iHnO6c`?^(07NXxKPN<#x^4Tp6Y>b&-!2 zIfzr05*7>Ntu3oytP-*aL-}bX^Db}Iq;cOROu2S6B4!6$0z^kIH!JcDTLA85uZhZ- zD|e5oMsxG?=WgWA4zfbq6d#qN&%I>yH79WG(XAe1<|;!gXrl>u6+ww_Ddc<-=aEmJ z;*k>V9(0$yCSaRVTDML!+)hs|fom^PZ`%rH%E}D+aV>9(m5!ckt5RJVI;=n@hZBah zRjr8<;3AR92jR6?oDhHF>Tl=#ejC^ ziw>pO{LY#Rrf}{#xYn+JHjZV8IBcBj364g)_vN@9rxc{kd^WpVZEO{{oZ_1DFMndAh~4x$_Sp{{#%=F3RHNHyUUSVXOxu|YNPn!*J``vE(sli+j{Xqyy!2z zDq3%*x+EpDm)QYN2Jy>C1U(rmRTwyjx zVVgjzlPdV~HVoPfFG4FYXq$o&-^3&om?K_u*`p*)B;{93U|hgX}pScFspq!5eQ8R z!xwGT)=ug8?YvKDOlD#0Ez*vlM-;mXJ|mBGqn)phjXyF&%kQs9BE@OJlR~Q&dmLmc zw0|Ir`DL%4H+z;UJhVRR%Ev~Hi(5)*Q9|v;>NxLibPaRPgpcGzyns5xI;MVmk&|m5*i2D{imu3hGv_}if6|J}8l^n0s%dc=~`#;N1p8JU9);vZ*L?^=*p zF@r(dDul@196kib-Z7ksV9gn--S*3~ugfeT)UwAao)g+zHFcDSrhUZiF{BQ7ZDR~e z{FSFVTDv_W1F9cxTr3hmPB$sbg!M(brDIifsd;pc){d6IJzYDq61B&-dVQAWIwO2 zCYEu%_mOPX0=St8!vWcC;p(gPE3@(4noJ8y<#V?k)})wG*s6m_JSjhw9swNuH{9PF z!iwU|dzJzojrQs2KWa3EdK@)D;%J>sf~Dbm&wH6#Sw<6F;b9-NxOGp#|MM$>XumyI>q;($qN z`)lxknUPTK;=gS4rfw_zb4Re9jOW*N{84#;U~K#U_u-x5#~cVZ2G6OEcMf;_{SCBI z@2!l4%Dz_*ER7cPk0SU5J4mYV;ul)8d1jzyEKPPNO&GvlaV>tfjFJ_Yzl7w-9fp>AEZ!>kNPB-6%ke@Bb7~$7 zJAs6Ff%pjX9sB;)d`Gl}Pwr)SeTi|e=%mxl_nGY&(+@!Z)2%Inm+BEDV%> zk-DqPuT^|ub#B44IQC5+t8HhqQHRVga2tEU0=P$#ku|o|Z6cYhz+zM^7gAcGv&}Hj zj~%Dv50S(kZ3x)I7c!>~!y4RqnHWpQ8ufcvwc%WEZLG6j$Z;63qNjPY*Gn`?Z7Z7M z`73yaQ|!+ag?Z(;9Qv&C7u`DEtY!PwN1oS9ts=$BL-ESC7p^l!PPgtDCPXafVm02K z*l$tNjWx-29Hh@GDsCTWy^*&=tCmbbB_I9{u49n6C1(T!oF2~ZWKBLDudMFYW=&uc zy<7dmw|Bw*&m(sX8zXqh#h(bIPZ7I|MW^-CJ7^v@!46;1b?uWh{A?J)E-~z_Zt}2k zn9sQ;Z}Gp$_baG-s#$uh`yQsBzzcmV6XXE zkAcyU`%&K4U2}+)7RmgK7kuPk@PYt~KaNBiIdYOQjDeF|(n-muL;Qr-7iC!zfLvBA zDu-=zabWR_k2cN+j+R~Xr42wx>ckrHC2tOKM`^kdLi6%6Kp{8qSbHJ1mQh|vpVfC zDjj_y1j9qsi7W41dogU29`X>?z`-FCahcF{)o(IFN2ow{FmT@ zuhjHgkp=a6bxBpHFU|mEuMt+w9SP@|X@-|gGK4(O8!Ov8G@@bndv_7qju#kXE1_0i zdxdx13I!8i1_=sPjM{~rAQQH5G;~Z3NPu9Ja2P452A0hzhi7aS7^e%SR{fuCWlz$R zj$Lp{K-O&Gz*Eg=%c_MRCg_ z_!q^qmfMwPs+XgBlY_RB&kr%w7K6;jy#hzLmKViQ@h)`~BkKGFep^z4eqsl02vRTt0WMQG&@`X)`*G;HL%tO+NhjO_lIn+&E=T8Tsb z>z@YA_&iUX5ZMpgrjdvF(xdVz`RR2Wr|eo27X!&w zp-KeFg{P{Pl?*H#F%>74j-WG+*7>M2ulFyA@H&IS_=(gHD*WNi?43>hM>lcSKGL9n z#HzAeTkin3huS-MI3tHC#A|sCVNH85bZa3l1(pOFX`>%8Mgnxq?Y9WmbV3T9nkZ!E zpOO*80~zoyCK66Ck8*;LwIYQVU4PYJ zDItW$*BKxEmqqU!g$$FSYIW#NPn@;4y6|kb6@53ZC%^me?w*wT(>1}-PM>Go?G5&t zNaA-o*wY6skV!MMy!$o-JKN*T#M$s=0Z9z6baZ(GtVZ0;K4tRSg;XdDQtRhSM?maZT@oM|go z^}4Y48;#FLFH`;YzoM$ca~2HK4~In__W%Lq5M%L#{6hrAcgG{ef$&>5tb3}D-1qmVCm2CRnSkGiK?P2}vY zPnZu;9lnFEWgfs=bI6#5bOQ*?-% zkwmcRiT?BaweJc9PXPEr`#_;?z zS#FcnFG7%OOYGyTH9N)62(*KH2?JU~aUrcDCDG?bh=I;)B}7fthvO(Q`5T$pi6x$1e;Y3A*>%hVAhX951(f-Z(S-hu#n#G z6{W48v&tM@836C%5q0>(BYkb;*v)sW3 z+Bk-<*nCv!F>Iof2FPSW%t68ffg?L8FviK^1flk&U15oB4Kld|&4(xL z7`a<8r$Jt7T;9edOW zy7Z97ihdS>s|}mfFmN^W#mNVy-{cm5vmjG9+a_aCNlkFuZGOOX!uAW-bn&E!3oFiA z*qAbyZSpP`ELjp?ojv~#li&{T7_Wh}DAmLpOa#b`=CE*2q<*zTi}62%+9HX?4k{Bjm+Jh32i47as62&E?9bV@y|N4vZC8w3GZU7v0QCh4uyt+3 zp>hPFZzMqd4%gW{6W*oAwv^snR`UMvY$1MrHy!iI^ac7$o?R1Oja^8xE+JBenlUx7 z?BU^|WX*753d`5TB*zhpNVX{PXgfRNYV@YW%m<3*9(M4O0GEVNfJG}8i*G=Y=ZhbB zCOSMsf8#pZg-Ivb(zbTOb{gD*r%AsA@N)v2`L5EfxkX~~_N^&(_8!O6ZJV{aEBu95 z-7c6!W8gXtqMm|O8xisy0U^9*{(M1iGR7z5Xg=+*fw$?ns}r^cvY0E3c`Nht-(+1N z^@mHZqP>ByUox=5($VpVjh<1pVm+ER7skPG4yhnv5-i_h+}y)C8rqy&0L3ZDbwg&YT*ywt#8Ak(V4 zl3bPWx#4WVqoVkDG&VUBjM-}hie+)m?leA+93qDzmbt{eiAg&$dUFf78Mm!9Wd zq~9?x;Ou5Hrb%`lcJui+E!51sAX{$ff>>MPm1>##8WU| z4M3o9x*#?E&ha3!xn587K#``Gb0zlzlkNFq=ZdAceRP0&mBlQ>ImI>6mI{s_5}}x@ zNg-G&w^hjGh}vKW9b*@)%!2B8TBn*H}|(e zjlwWXvu{q#I;|URalnh6SsSBTouK9%5*7^)cBA?g2#j<73Tt^=oLX^ffZnQmzLp$tWPf={ zgZjK!jIU=JwJpE4Jh}H$=s$X{b{Fei&UkR6#`P)1DL(W~%XAqev%9A?T8Y`J61O3W zP!3LewyGr2Q|ELJxU39GfV-7R2c*u(J`+vpRhNuwpE3Uur| zGqAuSV#d^WNPlzNiGc%`=PVP&`)@SGZ$GetkN~$s`lO5juj$pLUd6;jEzFkL5Tz#F zZ)>hzkAZcBOk&xjl`tM5Qtg9oTZ}hVu{xXPNUOOW7Xz4Z>$GlnpO--u1SWae?V@yC z^H6B$QjK$HjGlM}s}_n-4odPolS3CgWul<^1jc69x&7uLkw15cdLs}_K7th>0Wh!) zhBq4z%OC!;z!nej1-C+K$mRj@13N1k3*V$&4)I0BgFkS+DZ1*$xKs(bu}W7<3fZP$K!nSi%n zRIr$i$#!QPsOsrmP$S-zWp92|l+7h!JDVm%FhLboVW_VPlHo>R1m6{pS9&DVmFyFQ zr-x2YXh0LT`kd>qIv;R~;peoTO&%qjdueX})XNJ~RG1m`WvZWlps0xfbDT$+i)FCt z=h(;@1Gd#E>Revk9_34JDsy}+lZ_Pm+5J7JD$?&gQt1&?apG-pY;}{0%Oa(nPr(Th zFiY%U!Ro{0+gas@{G$3kYq(isFHl8KpOinCUObcJ^(F?V_F&F%s=}MbTgYRZ2=+pOyz??@utgzBuY9M^Of|ZXuIY@?xvd_r z3D3lSUHtfae+w_&f5ls<^4_FgBmq#R78hS2zrQ=vt8u?u8KWKYC=Hi-A)AvjTC^?# z;74yJK6n_1xdP^%?1j*@p?wVd!cDNPVV@x~beK8LWro(nll|N3oVle${H1cQPqefT za?9W~@V1@4m6HK?PoY?3#ws8&3F|oKl466u~as{$kynCe2k$z9j4C_jK>;&n3OJoVH>kX0{mdJ>om5lHurCDk@zcXh3tF( zLIE$>t8Jz%d#A3j)0BQ8*XH}?@zaZg=_f?}a8v|X&u#?Qy$iiJo%Z*~%Q4>AfBijtA^0jfYFxojQ`*I7!r zZBHyiUYnnc7pJ{$Az*mn*e5G0lOTR<<)Cyp1dB<|D(KWmO3b9gg97i~Z@~uZQsuq4grm1?xB$!W8W|F7cv^ zy?zz!QSiu#hH#y38A0GXd8rYwAHTFB1HrzKk%J#z&M`7*ezK9~AFW>-l-Z_`LU|@m z)MryX18M?RP>QQ#AiuMeY&Gl^@*;S-@1dcEx8|~jR-~@E=7kl^KFmO^@)_tbFe(B5 zbvKo}rfuP{P^;K`5@6pNL@#>SZxb3e zaI~ajRt($W7!25$p6_4HC~?kaxblJ@)b9?>8OP<*j@e$Vyil6~t070Nm`u~D8U(zS6=+N#qf_@RU>=FQ z(qr{`oKFIDdyg$HKK1Objjj;KeAbaI-r0zK-J65?;LtJ2EJ37?W+FVfW$Fxk+xx($C1 zIuIDwsEq9X0=<_lLZ;7LFwN7dZHm}`S*0EI4rag6+Tq2rjBt6SVL|Q`9CL;BD{@Ue za!s3v@zbGYVOBi3u&E0nZW^LO8yuECc3qi4F7nHp-Z2^yM~;s$sP@GE{WZA;7FJR0%;6)oA&iyYH}S1#x* zm}WN{{B^3Wf*j|>?cn=)KDlTM#i0ZfXLvQ3yDsH-PMykoe<%!$;@e_N59~$=J6_eq z=31Uq0_ot=&VRz>F~!GT{03R7kuari`ps{Esi9X*UWz1! z@91|o*FckYG@H75`*$SZ7Y;9OC2PN3zx;*=A~S>-=ok2=!dL)(_2U&k_UcE;`8YcM z8|WPOL3jR<#?lO7ac z)FuI5I1mDkdseG)?t>+qWz(gV*p)eCkUXKvFbfrT)$HWw{8af~_VU@zG-AkftnO30 z0Y|IYLq=nHWr9O70vE?bv&!Di#v4RZTx(1PvITZ@z}AFyUgGh9c|mktfRk&P@MMIN zsh1WT>YFUQs77J4racxp3}y|g>TJo#iBF(eyFl%**wqKMOJBW;WmTGX#Eb~>I6k-+ z_S^Kv<@ejy-41dw{A>^`2-@do1~VU~Sy(j}KbNdFzd{oipF#}xO6(z9u(up(kR$X! zt4ET}vz+>8@ZVRH5{1^o2QEF{C}L9Dg_9{TuL%zEShhD`=ydI+?0P7BIFLmW=UpdR z`z@-wtQ_!V;P->lZ)LWshb>noEjs_$*_88YVbE;%QB{lzld&wzQ!at+git%4m0OeK zbVx+NbNWiAj9~v%vL8mTF|(`*;}cSqW1pRP{V`ox9u>zAC%tAaif1OYM3y&_9!5_v zko|1kr(W05#YE@WLl?c<`2()f52H`@WtDhYYv~Ucb_4{?%B|QVnYrG&Db{a#4O$D1 zy(3jh02|Gi;~8M(GG})~(U1b6P|-8S%|Gg9XI4|Zs`XTvrwPo(rc^oY49qx3E$LN$ zIWr^yh}Xjt)b5V9i|HKkYz@l-e|roLfC18E_x+njz{j4osr&}(qITFPmHiKp`G_irFyyRWlMxGG8_uJ&ZAW0w?Vq1BJ~-^R{2Cv8 z{&-r%$1eFzYCcNI$AR;IOqaYgB@~0V4c+9BL$P{v5+o=(XPSCw_pVT`es9_*zvn&p z(|!FPj1F!HQ*btl3PsofOM63JRC_1RQ(sc_@0ukE9m(D7iL=+a@mhva$wn3ufZIWR zTp^N~x3?GE%ce8jswbkZ`Q=(@>iZ)|6sz&BM!diLnK*Gf-q~w9R7+r4DEbPviQL6v z^ocG(&(Q9Qn-3&oJPa?WG2`Lb;G%1A0FB?Q(?$p>(iM1ROs#sw&hFI6V4|D-c;*_P zcwJ?ffgMRBF1Q6lH?AitPE4Hx0x=IE89`jV@?PS?meq>5XGJ-BgZlq)PjtGG%C$!= zx5_q`7=dwv#`(qh>E~e*WlF>4-WFvFlQQ} zPw4Em+&y~LbD%a}m5ZLOS}>_+-eV6lAB7K_N{Q^w)Q3yO*7ny%*Ta9vR#c^zcwRP`q%z*a3rz0j0_!Kb#W*F3* zR8~Oz+P;TA<9N7Z$P4Nj)+jJPmKY)dG98>+$LLzr=EW5}I61(z!aP#>l&BNtPq-aa zm%uI+Zh|TSgl^Tf=LU%fyJZ7PFUDJcdHQ4s6z?ZWXA!v|BQAdZfZiN(@iv zoVN2%=u|6%*|w&ruXZ?<6(AQ_{&u6#5vr{&n&yScz# z=TIDr;#Kg9uVR3SS!2>HD*R0%5C1ct=I*u(NAP+PX{Rc2(Xh&}VsL>vkAWuO#4Ikx zjd8a3UYkbl;I7%Z8Mu7|9f?p%dvcP}v58t2_`5OnMY%NzKDi-V6_+FpQjRQa0ps;ukX+np!OG5&k&nQ-K+II(+>?y%sG{Dgh(2efCuM5Wq868$xBTv+2orIgE9?zQeS zyS4LzG!cp#T7;~g0*rBB=P=I;jN@>IpjGhJvtHd3hntJ^qP)!}9~d=Y_S_7zG+NQJ z_G;&P_T%TUOKW|T#Rzn&3*JlUnaEQcdhO;Tf3t8)%u!<>nO`0YJ5l&+Rt@vYkIUS5 zs<YBG55KOBmQ_(klTfqu1{Azfa;QnfD|f0vl0Z-57I&=Jm@@;*_yP za+TKzev)1m@Fv!Jt5aE*H^wUEA0;`A*t4`JQn2TCqc@qLM0k0oB&g=v z^aD+c2jLJQQa$pDP=*$kNZda(u9MWPGH8-`gOB1NdN6-MVoBS~qaYU4e(ADM=nRT; zIMF+C_W)fJkLwD?!eO7z_#erquhg(er^UBlQ?6T@kGHBbVH2jhXkDHJ_>&5g@c|76 za0+XrsQzHzQmvF~cEj2=-6d*F$d;=PorR<>>+|^u&xh>on=1@@xD#k<5JE!&Ot}-m z4+{8zM{{u9gIe)8p|;H)QB1Si2cA_op2Vlj!Klg&aScI3p0 z(_-tJ0ji5oX-4I*n)``av3qHo&n>=EW(~>QR>>XPVM4kp0mk(52v|!3tvuA~SLX;O zi{kJoO3s)X+GJu84TJsAryp>qqCYHL3L#RnqYt7TT5qa(^oyP*n8w7zxz1|zJuQ-L zS_k-t<^?22Qj#0xZLIj&Rok7W=?QSi1`8F_l*jy@JJz__^WPKV&AE#N9TQGsLLB;>fYYeWG<`;J@ZD-TCSuf%;*UKj1K> zZ8-<0^7@w?ri0QuBItivyZUt4Px|qC|05^tV}Jf@A|UWlUj8MA@V|?^*hlS~I7oBN zH0v?qCyJ2#A?H7`ILiD%FM1Kz@{pk>%-e(zi_mLXD14S_7M&`qqZkR0o^WS}x&6=2 z9!^|FU#Vn{ziQ=@68St!ACKG4h|%M6D^-7(yez7}1P+19?Nuk0k6ybHl<%OGa$m==ebLJ^v_tN~&Lf`+^| zC$3MERVuK!5*Sl=^c$hK0!^O|J8Y?rF&R@ghuj%*^s8#5O41Obxk@!~JTVjodH&^O z9RvDcpoyoOhB+pfN``a#^*X`zI@3$ia3`hOa?z=Jae@E|a4W5-v)872zWq^ZV13Ae z@)#={cg>ooMrR<|I6oiXzk94GoVHyXuN@6utv<1umGbgvIf7aTjZs-3Nu;v6{&-gJ zlZu6DyKleD2q&5Ow+aW~@X6~a9+FcH)IYX~F!mKTJ20N@u%A&p-@v~b(*ttyr!*8V zOMst=LuTVWK#JZtpz?joX&=ETi?VTO8+lI45#~zH6-PFaAzRN1RJ5uy}H zO+tM+6o7h>KOGVOBE z9#D0`an<=M!e!m#6C^lcga7LN1^b+#C}(FU;YbzruUit^=g`YyLv7BvsPzV6-y&sbl>>u^D4EmH zfbe?PkQyAfrIMmsSVX@VobxK25BaBU*{$)6JTGd^HMx>us#jcYGI6#y;x!#fM^U1o zhVx-&J>JDi7IzziyyG}$N(|0X=QqI)8=}2gmA&=T_}*b{qxP{N3u-8n4?ljyNbEqG z@6F^ZTKCO2U(?GfBsn@vwY-Mowom*U*@oUHK$m%+;iCAv`8Ty&@%P6%3Cp=l1v!+( z4RCBK)*|e z*miivP45vg=k75t_ONhM2F6Lpdl`&#iRW@wl*vUA0Ph$Pzr~%FiHMuI`{o4lat(PU zJwDYVB;2a&1{~t$BH&%pU*$#n=_ez2FS-=JrBo&v59n}W%>w2u!udQb-l&Cp7=8rI zwS|Pci}r&r{4kz0T+d)~)?NREGqKjbffYvWjQxi_aq$z70=1%yT%9aDXR&X(Ppll+ zL({2UEX)nx&nh|>0W8zmA$5J047Un?2OaH5qZjsdoLsPP*1T0#%{s#f-*-eLG+Gt6 zI8Q#(#hqg;o7K3%uZ*3I-vMuv3M8pW1$J9<70E9EylWwu@&L zlKw5z6aJa_JN3;QrOVfnF4vcxtD#^j^Bfxd<9x%QN|*{im6f)BkvjdO3V%cg+81pA z{S0&ALt)d#h(>;gNIP|Z*C_75Y2H*e-8c3K>vEd zFC<8t=`m;s4z3{I%m8ELf6Y>x^1`ZthFZG(ojk3{6V-%Pc92>$Cm-bb-E;BKV~^=? z2Ma4>_B-*%f&rHgIdmUz?a-GPyUYovF9j#J8)#D}yDcvqY{ED2%3EOV+7YpvO zm+hMOd`7dQxA|xm3$ub0z-TcqZQId*O^+*@G@i#IX^B7027FeiUqN+8msV#si?%%u zS8u+|qbOEUNgm1e1x{b}+U~Dd1VfuugevnYqiNeh{&jhTNN}6ekfU3Lk8bFV0+pxR zCWLvlag(re^?23^GfNAK!enV1%G^I2G~16@F!X+vjN~K%!++6Z4Cqw_Zs#hOaZ0Q4 z>CH&h7}8v31gf$be&CFgD7Y#g!2j!72in9MF%$ol|_d~e^ZSDT5PV?@ebTkz)cs*@f+^L=~JL%_z-_+oW z-q`!m#VZ<_Fe@z5t57!?r#~|?(?&QDwA1{0IJrI!(;|c>hlI;7{)(96E5)3Gw7R_P zMm$!E2E4VE-3U*wrZR?AAs7_tz=S_iX=R(fSW_<&ZSLJesx+%879YheqhQUN{T5^j zsDf%k{Hm9X7_I>Po3@S1T{IyjTfg-)JK%8)V>Ey{Q8574mW{y%vV=!Hp-82EV;nYt z>RM4&ODcbG-O4%vK|;aNgwZ0aiByzW{~mnZhP6C>u-CcE6;9OaBtirJeU67KDk+qw znUz(K(I;4=ZvJyjIEDN=T)mLVB%l==?TnJId2Aba=SmoVXqq0O6>ENg!>EC4HR2RY z?)uBxN&w>poiM`n4$SiBbCjNpf~E?Evu1K3$B{5Y-#ynY^x*U7adjNcTSuk~eD=`& zMNC(JI5E>FMaaJ_GXIz-OLVkAjr<_-d}YMv)~Ez@f-q&M3tA~Up2G#F%T@T04jd?_ z4kg1W0Kk#8bbISI9R@%A{O4hJzDWOg(prCg?1+}$;y%>qxrbcd1TH>Yh->YR+!1F5~P)WI`xlR{?bonsaAP=VHR z&%>0B-L#I1@`Q-RVj;ata5#k$mp^3J7Ubb#SizG3v#O8PuSbjd(5B^Fk;SmJed`H5 z!jjq&oEduvrvK1mHj<1Zc4(xyv585n&I|VlExA4;g!{H|+DVCa+W(xb*+vtY5 zar}d^V5^0YamUcv*Xzo&ht4G_8I{*B(_6e^4e>rZ$_qt@3Q=myBvuCP(6_bf>3a<`TdG5fH%(iw#Oh+BZkjQO*q5X=^ugATUXh1baKpj=wC)rT2b++JDUcLhBC zZb}`pAMS6;#TE0lhlz5V96OT?>}4oIeX#<|cEH{Dm z67SZm=v%dz3@a+y(Q9@+VuWF)XX5XNR8rSc8v!@KFvvj)jl<;Qy#jteEJa`nS6vQsFhW=72tN?b*oxWG&0Q&t6mX z5*2IU>s0*{Y$`~Dr__Oh=%PQ(CgoC&5#k*J6sFMq|7q{)gPOXsIF@R+cHJykpuj@9 zqM%}A{Q#;WQHO}gVk#m*(!dr13K+78i6MqOMr$#!nyCy>%}PY^1A$>RK$L`JM!rRo zf+P__5)-7zM<5>v2}z!N-L?+x&UR-1+SwWZcr$P2+&A~lne*URE21C9?CH1J4ruhg1RgI~{G@yI3kG+!c%y?moL1jYLcwcV##%W$YG`;Z zq4TTRIr){i;wdKX8JeLW&-W85l0o6jl0iGA*S?M5iweM8k$69}$9SAcA+6bvxKupo zsHV*E8_fL?W(IdIbV_x<0R353F2*3|>ppw6t|9BtiRPUg4I$)3@v;HW9BXsT>P+2juHoZM_L+aNQAtngfTkO z%J>Io5T7Jjg7nd(+jkxJvy^#2L4?ZfJpF>lVA;Ko0lzre!(4mFZLRU~Rp@c$ zd`Ta^)m_gLr|h%6pbmPi!jbX~AGOEx_RZ1DICnAtK_#C)8!CVLSk8AZLr#_={7m$ok&R(O;`=E+d-t;bXS(&>q5={f`TX zLUdy=o&eYySLEUi2v+()Lr}w3AY*5mvu?fxdRJFp4Got*SMEZ3(r-nMqFmnA-2e!R-)}?7n1FJzk}MNQ?8s7=J4|F zwfk!>5l^yH?Have*qp)iws5Xl&{}cPv>&gqHOa>?#&SOrP8eeRb;I^5{A;JP^MT#~ z-Dn&|dW3!XTzWo)xzt$LCYODBPj;Nbs$@swsSnt&|D~!;Xzu8(wnC8qDyVa!1$@Os zWG1aOsKiC}_T5#MENn~jY42^pQKQ9r*N$RwdKb~^H!MmOwbh9&N5!rQ2@?u9P_Y$S z5qV>ReWTY=iR$bI{Sl;BQxIZSMF)G$z%QS(S19dR!%lT|jvz{OxHzs~ zGa3Pfd;rAk%}>YFoW2kR=sAdnU8L_>1g*&B!k`S}GD&9#7u7fsYg$`Ie8ML+6R=P=S#(hg#iXG-nh3; zhgv_e(6gJoe6K0fH50qO9Z|mi(YWnfqIJG8-%U$crqlwJ7LpFj+JZIM5{)EX26fY5 z;@0~^%8s7A+|fPeNL#f_Wij)$)1?-__~Z|F*U%<)vbys|;_d`i0G{6a8OAlgzKXOM8Twi<+Mr%n@m(snh3TBfSdI8KDmUU-_qFp>8c zCR)~2Cmf?UwOq55V2lxT_l|*8{n=~E@HGB$Gc|0J^&VMi27%R*oM3_4)UI-%VKZ-N z5&Hh_J4zk8O@zF-E+8-EDMip#PUI295|~ahf#zZBWzllRK0{=oJw}K0j0!zlt4AfN z7@gKvQN*;tqpp7NE94B2+S$%%SkgJc{g4q=#farwFqE8Q3+?T)n|FJ>`GcoaRR`Xv zm!WB*quoiTs}o{wPT&&bro6JED%?SaU?jhE!B1Czo+?jn{F<|Z=x)HICMCR#b44=@og;ZIBOrIc!WOj>RJ^<$S&5U9luWXX?Zg&UF5cAcE;nHT=wCGQkbw-bNq~}Iqo_73w*i+v4 zfgEMu#jAg&pQx_(ovin=03qA8%x>a* z5am|JrG}91&3S!OMR~$*NU7R|5Q;R7o3>_nL-&oh9*+P>K;O&RqU^#!YLM41*d8NoEO~Rd zY~A~&ji$#j)Cnua@GSHLysZ}iD(uJ~0q(WVskwUMFjZ_&;B`{3EZ@zX=zPpy&g}H< zK!wY9924HWsMef6B^(DOsWNn183Cj{Aye_@B{Ch&7^LHkbY6KVLJv;@qWInC2)&i3orX$ZR6#RNw%90~{US zu-q)E#}J`kF;7&iw3O)TmyI`(VU0F79g4AxC$rHR+Ay-Fn5+ zBR})Q!NneO%IbS(eH$%{LGCr<+OZ$)fsrc^yl*eo_F!0m8^XtKX0`^RV-v9sr literal 50434 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

ZY^)qm~j}Bno#;h2BHeg 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 b`O&=b&D`_Pc|+(gasK-Mdp=D2W#<0@7BT^x diff --git a/apps/docs/public/static/search/source-sync-history.jpg b/apps/docs/public/static/search/source-sync-history.jpg index 84151025f0db3aaa60f680903ddc80dbd14ee51b..59b7ef8abd6ccc14b1d9219d22fb2fa2e88d0c7b 100644 GIT binary patch literal 19147 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

H)Z8%v-*!ga?JM!JXrmqyFN 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 U|M~s@)Q$htjX$d)h(D(O3$~IFa{vGU diff --git a/apps/sim/app/(auth)/verify/use-verification.test.tsx b/apps/sim/app/(auth)/verify/use-verification.test.tsx new file mode 100644 index 00000000000..bb09a6d84ea --- /dev/null +++ b/apps/sim/app/(auth)/verify/use-verification.test.tsx @@ -0,0 +1,102 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + refetch: vi.fn(), + verify: vi.fn(), + resend: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: mocks.session(), refetch: mocks.refetch }), + client: { emailOtp: { verifyEmail: mocks.verify, sendVerificationOtp: mocks.resend } }, +})) +vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams() })) + +import { useVerification } from '@/app/(auth)/verify/use-verification' + +function useTestVerification() { + return useVerification({ + hasEmailService: true, + isProduction: true, + isEmailVerificationEnabled: true, + }) +} + +let root: Root +function renderVerification() { + const result = { current: undefined as ReturnType | undefined } + function Harness() { + result.current = useTestVerification() + return null + } + act(() => root.render()) + return { + get current() { + return result.current! + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + sessionStorage.clear() + mocks.session.mockReturnValue({ user: { email: 'member@example.com', emailVerified: false } }) + mocks.verify.mockResolvedValue({}) + mocks.resend.mockResolvedValue({}) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.innerHTML = '' + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + sessionStorage.clear() +}) + +describe('verification after opening an enrollment in a new tab', () => { + it('resends and verifies for the signed-in user without signup storage', async () => { + const result = renderVerification() + expect(result.current.email).toBe('member@example.com') + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'member@example.com', + type: 'email-verification', + }) + act(() => result.current.handleOtpChange('123456')) + await act(async () => result.current.verifyCode()) + expect(mocks.verify).toHaveBeenCalledWith({ email: 'member@example.com', otp: '123456' }) + expect(result.current.status).toBe('verified') + expect(mocks.refetch).toHaveBeenCalled() + }) + + it('uses the current account over a previous signup address in the tab', async () => { + sessionStorage.setItem('verificationEmail', 'previous@example.com') + const result = renderVerification() + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'member@example.com', + type: 'email-verification', + }) + }) + + it('preserves signup verification before a session exists', async () => { + mocks.session.mockReturnValue(null) + sessionStorage.setItem('verificationEmail', 'signup@example.com') + const result = renderVerification() + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'signup@example.com', + type: 'email-verification', + }) + }) +}) diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 5927438998b..b2009abdbbc 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -75,18 +75,19 @@ export function useVerification({ isEmailVerificationEnabled, }: UseVerificationParams): UseVerificationReturn { const searchParams = useSearchParams() - const { refetch: refetchSession } = useSession() + const { data: session, refetch: refetchSession } = useSession() const [otp, setOtp] = useState('') - const [email, setEmail] = useState('') + const [storedEmail, setStoredEmail] = useState('') const [status, setStatus] = useState('idle') const [isResending, setIsResending] = useState(false) const [errorMessage, setErrorMessage] = useState('') useEffect(() => { const storedEmail = sessionStorage.getItem('verificationEmail') - if (storedEmail) setEmail(storedEmail) + if (storedEmail) setStoredEmail(storedEmail) }, []) + const email = session?.user?.email || storedEmail const isOtpComplete = otp.length === 6 async function verifyCode() { diff --git a/apps/sim/app/api/organization-credentials/oauth/route.test.ts b/apps/sim/app/api/organization-credentials/oauth/route.test.ts index a4f0499298a..f666a71e7fa 100644 --- a/apps/sim/app/api/organization-credentials/oauth/route.test.ts +++ b/apps/sim/app/api/organization-credentials/oauth/route.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => { } as const return { organizationCredentialOperations: { list: operation }, - listOrganizationCredentials: { operation, execute: mocks.execute }, + listOrganizationOAuthCredentials: { operation, execute: mocks.execute }, } }) @@ -45,23 +45,26 @@ describe('GET /api/organization-credentials/oauth', () => { credentials: [ { id: 'full-credential', - displayName: 'Full access', - providerId: 'google-drive', + type: 'oauth', + name: 'Full access', + provider: 'google-drive', scopes: [DRIVE_SCOPE, METADATA_SCOPE], accountId: 'private-account-full', encryptedValue: 'private-secret', }, { id: 'limited-credential', - displayName: 'Limited access', - providerId: 'google-drive', + type: 'oauth', + name: 'Limited access', + provider: 'google-drive', scopes: [METADATA_SCOPE], accountId: 'private-account-limited', }, { id: 'unknown-credential', - displayName: 'Unknown access', - providerId: 'google-drive', + type: 'oauth', + name: 'Unknown access', + provider: 'google-drive', scopes: [], }, ], @@ -110,6 +113,60 @@ describe('GET /api/organization-credentials/oauth', () => { ) }) + it('forwards browsing intent with the acting session and projects a managed choice safely', async () => { + mocks.execute.mockResolvedValue({ + credentials: [ + { + id: 'managed-1', + name: 'My Jira', + provider: 'jira', + type: 'managed_oauth', + scopes: ['read:jira-work'], + encryptedAccessToken: 'never-public', + }, + ], + }) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/organization-credentials/oauth?organizationId=org-1&providerId=jira&purpose=browsing' + ) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credentials: [ + { + id: 'managed-1', + name: 'My Jira', + provider: 'jira', + type: 'managed_oauth', + scopes: ['read:jira-work'], + }, + ], + }) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { organizationId: 'org-1', providerId: 'jira', type: 'oauth', purpose: 'browsing' }, + }) + ) + }) + + it('rejects an unsupported listing purpose before the use case', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/organization-credentials/oauth?organizationId=org-1&purpose=all-members' + ) + ) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('still returns an empty authorized list without fabricating a credential', async () => { mocks.execute.mockResolvedValue({ credentials: [] }) diff --git a/apps/sim/app/api/organization-credentials/oauth/route.ts b/apps/sim/app/api/organization-credentials/oauth/route.ts index e6e784b9a45..f354d397279 100644 --- a/apps/sim/app/api/organization-credentials/oauth/route.ts +++ b/apps/sim/app/api/organization-credentials/oauth/route.ts @@ -6,10 +6,9 @@ import { } from '@/lib/api/server/routes' import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' import { - listOrganizationCredentials, + listOrganizationOAuthCredentials, organizationCredentialOperations, } from '@/lib/credentials/application/organization-credentials' -import type { OAuthProvider } from '@/lib/oauth/types' export const GET = defineInternalJsonRoute({ contract: listOrganizationOAuthCredentialsContract, @@ -18,14 +17,5 @@ export const GET = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth credential listing behavior' }), errorPolicy: internalCredentialErrorPolicy, mapInput: ({ query }) => ({ ...query, type: 'oauth' as const }), - useCase: listOrganizationCredentials, - present: ({ credentials }) => ({ - credentials: credentials.map((row) => ({ - id: row.id, - name: row.displayName, - provider: row.providerId as OAuthProvider, - type: 'oauth' as const, - scopes: row.scopes, - })), - }), + useCase: listOrganizationOAuthCredentials, }) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts index 146a97011f1..087273cd537 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts @@ -20,8 +20,6 @@ import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/ const body = { appId: 'A123', teamId: 'T123', - clientId: 'fixture-client-id', - clientSecret: 'fixture-client-secret', } const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) } function request(input: unknown = body) { @@ -43,7 +41,7 @@ beforeEach(() => { }) describe('organization Slack setup route', () => { - it('authenticates before parsing setup secrets', async () => { + it('authenticates before parsing setup input', async () => { mocks.session.mockResolvedValue(null) const response = await POST(request({}), context) expect(response.status).toBe(401) @@ -67,6 +65,12 @@ describe('organization Slack setup route', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + it.each(['clientId', 'clientSecret'])('rejects a client-supplied OAuth %s', async (field) => { + const response = await POST(request({ ...body, [field]: 'client-supplied-value' }), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('preserves refusal when current organization authority is insufficient', async () => { mocks.execute.mockRejectedValue( new OrchestrationError('forbidden', 'Organization admin required') diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index 3b01f425c50..970dcd47e6b 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -204,7 +204,11 @@ describe('focused Search enrollment', () => { session: { id: 'session-1' }, }) await render({ returnTo: 'search', optionId: 'site-two' }) - expect(document.querySelector('a')?.getAttribute('href')).toBe('/verify') + const recovery = new URL(document.querySelector('a')!.getAttribute('href')!, 'https://sim.test') + expect(recovery.pathname).toBe('/verify') + expect(recovery.searchParams.get('redirectAfter')).toBe( + '/credential-groups/enroll/invitation?returnTo=search&optionId=site-two' + ) expect(mocks.read).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index f5504cef26a..63423f76409 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -138,21 +138,21 @@ export default async function CredentialGroupEnrollmentPage({ const { token } = await params if (!token || token.length > 128) return const resolvedSearchParams = await searchParams + const callback = new URLSearchParams() + for (const key of ['returnTo', 'optionId']) { + const value = getSearchParam(resolvedSearchParams, key) + if (value) callback.set(key, value) + } + const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` const session = await getSession() if (!session?.user) { - const callback = new URLSearchParams() - for (const key of ['returnTo', 'optionId']) { - const value = getSearchParam(resolvedSearchParams, key) - if (value) callback.set(key, value) - } - const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`) } if (!session.user.emailVerified) return ( ) diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx index 9dd3e3fb56f..5d343df275e 100644 --- a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx @@ -1,14 +1,15 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { cn, Expandable, ExpandableContent } from '@sim/emcn' import { ArrowRight, ChevronDown } from '@sim/emcn/icons' import Link from 'next/link' +import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { useApiKeys } from '@/hooks/queries/api-keys' import { useSearchSourceOverview } from '@/hooks/queries/kb/connectors' +import { useAuthorizedApps } from '@/hooks/queries/oauth-provider' type StepId = 'connect-integration' | 'connect-sim-search' @@ -66,14 +67,24 @@ function StepMark({ complete }: { complete: boolean }) { * the workspace home's suggested actions: a hover-revealed disclosure header * over hairline-separated rows. Each step leads to the page that completes it, * and reads as done from the organization's real state: a source the viewer can - * search and a personal API key for the MCP server. + * search and an OAuth app authorized to use Search. */ export function GetStarted() { const { organization, viewer } = useOrganizationContext() const routes = organizationRoutes(organization.id) const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } const { data: overview } = useSearchSourceOverview(scope) - const { data: apiKeys } = useApiKeys('', 'personal') + const { + data: authorizedApps, + fetchNextPage, + hasNextPage, + isFetching, + isError, + } = useAuthorizedApps('', { enabled: viewer.canUseSearchMcp }) + const hasSearchAuthorization = + authorizedApps?.pages.some((page) => + page.apps.some((app) => oauthScopeSatisfies(app.scopes, OAUTH_SEARCH_READ_SCOPE)) + ) ?? false const hrefs: Record = { 'connect-integration': viewer.isAdmin @@ -83,8 +94,9 @@ export function GetStarted() { } const completed: Record = { 'connect-integration': overview?.hasSearchableDocuments === true, - 'connect-sim-search': (apiKeys?.personalKeys.length ?? 0) > 0, + 'connect-sim-search': hasSearchAuthorization, } + const steps = STEPS.filter((step) => step.id !== 'connect-sim-search' || viewer.canUseSearchMcp) const [expanded, setExpanded] = useState(true) /** @@ -95,6 +107,25 @@ export function GetStarted() { */ const [animationsEnabled, setAnimationsEnabled] = useState(false) + useEffect(() => { + if ( + viewer.canUseSearchMcp && + !hasSearchAuthorization && + hasNextPage && + !isFetching && + !isError + ) { + void fetchNextPage() + } + }, [ + viewer.canUseSearchMcp, + hasSearchAuthorization, + hasNextPage, + isFetching, + isError, + fetchNextPage, + ]) + const handleToggleExpanded = () => { setAnimationsEnabled(true) setExpanded((prev) => !prev) @@ -135,7 +166,7 @@ export function GetStarted() { would hold its full value through the close and then vanish on unmount, snapping the content below up. */}

- {STEPS.map((step, i) => { + {steps.map((step, i) => { const complete = completed[step.id] return ( ({ context: vi.fn(), @@ -13,6 +14,8 @@ const mocks = vi.hoisted(() => ({ consume: vi.fn(), sources: vi.fn(), apiKeys: vi.fn(), + authorizedApps: vi.fn(), + fetchNextPage: vi.fn(), })) vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { id: 'reader' } } }), @@ -30,6 +33,7 @@ vi.mock('@/hooks/queries/mothership-chats', () => ({ vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer })) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchSourceOverview: mocks.sources })) vi.mock('@/hooks/queries/api-keys', () => ({ useApiKeys: mocks.apiKeys })) +vi.mock('@/hooks/queries/oauth-provider', () => ({ useAuthorizedApps: mocks.authorizedApps })) vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({ MothershipChat: mocks.renderer, })) @@ -45,10 +49,11 @@ beforeEach(() => { mocks.context.mockReturnValue({ organization: { id: 'organization-a' }, searchAccess: { memberScoped: true }, - viewer: { isAdmin: false }, + viewer: { isAdmin: false, canUseSearchMcp: true }, }) mocks.sources.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false } }) mocks.apiKeys.mockReturnValue({ data: { personalKeys: [] } }) + mockAuthorizedApps([{ apps: [], nextCursor: null }]) mocks.chat.mockReturnValue({ messages: [], isChatHistoryPending: true, sendMessage: mocks.send }) mocks.composer.mockReturnValue(
Question composer
) mocks.renderer.mockReturnValue(
Chat history
) @@ -65,6 +70,30 @@ function composerProps(): ComponentProps { return mocks.composer.mock.lastCall![0] } +function hasCompletedMcpStep() { + const link = container.querySelector('a[href="/o/organization-a/settings/search-mcp"]') + expect(link).not.toBeNull() + return link!.querySelector('span[aria-hidden="true"] svg') !== null +} + +function authorizedApp(scopes: string[], clientId = 'search-client'): AuthorizedApp { + return { clientId, name: clientId, scopes, authorizedAt: '2026-09-01T00:00:00.000Z' } +} + +function mockAuthorizedApps( + pages: AuthorizedAppsPage[], + state: { isFetching?: boolean; isError?: boolean } = {} +) { + mocks.authorizedApps.mockReturnValue({ + data: { pages }, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: Boolean(pages.at(-1)?.nextCursor), + isFetching: false, + isError: false, + ...state, + }) +} + describe('organization home', () => { it.each([undefined, 'chat-a'])( 'does not mount Home or chat %s when Search is disabled', @@ -151,7 +180,7 @@ describe('organization home', () => { mocks.context.mockReturnValue({ organization: { id: 'organization-a' }, searchAccess: { memberScoped: true }, - viewer: { isAdmin }, + viewer: { isAdmin, canUseSearchMcp: true }, }) await act(async () => root.render()) expect( @@ -170,6 +199,79 @@ describe('organization home', () => { }) } ) + it('does not complete MCP onboarding for an unrelated personal API key', async () => { + mocks.apiKeys.mockReturnValue({ data: { personalKeys: [{ id: 'workflow-api-key' }] } }) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + expect(mocks.apiKeys).not.toHaveBeenCalled() + }) + + it('hides MCP onboarding and stops authorization paging when organization policy blocks access', async () => { + mocks.context.mockReturnValue({ + organization: { id: 'organization-a' }, + searchAccess: { memberScoped: true }, + viewer: { isAdmin: false, canUseSearchMcp: false }, + }) + mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }]) + await act(async () => root.render()) + expect(container.textContent).not.toContain('Connect Sim Search MCP') + expect(container.textContent).toContain('Connect an integration') + expect(mocks.authorizedApps).toHaveBeenCalledWith('', { enabled: false }) + expect(mocks.fetchNextPage).not.toHaveBeenCalled() + }) + + it.each([ + { scopes: ['search:read'], completed: true }, + { scopes: ['api:read'], completed: true }, + { scopes: ['api:write'], completed: true }, + { scopes: ['offline_access'], completed: false }, + { scopes: [], completed: false }, + { scopes: ['unrecognized:read'], completed: false }, + ])('derives MCP completion from OAuth scopes $scopes', async ({ scopes, completed }) => { + mockAuthorizedApps([{ apps: [authorizedApp(scopes)], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(completed) + }) + + it('finds a Search authorization after the first page and stops paging once found', async () => { + const firstPage = { + apps: [authorizedApp(['offline_access'], 'other-client')], + nextCursor: 'older-apps', + } + mockAuthorizedApps([firstPage]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1) + + mockAuthorizedApps([ + firstPage, + { apps: [authorizedApp(['search:read'])], nextCursor: 'even-older-apps' }, + ]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(true) + expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it.each([{ isFetching: true }, { isError: true }])( + 'does not start another authorization page request while %j', + async (state) => { + mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }], state) + await act(async () => root.render()) + expect(mocks.fetchNextPage).not.toHaveBeenCalled() + expect(hasCompletedMcpStep()).toBe(false) + } + ) + + it('clears MCP completion when the Search authorization is revoked', async () => { + mockAuthorizedApps([{ apps: [authorizedApp(['search:read'])], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(true) + + mockAuthorizedApps([{ apps: [], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + }) + it('sends the member question as an assistant turn and clears the draft', async () => { await act(async () => root.render()) await act(async () => composerProps().onChange('Find our launch plan')) diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx index de88160942d..636a3fc5080 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx @@ -3,13 +3,13 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ organizationId: 'org-1' })) +const mocks = vi.hoisted(() => ({ organizationId: 'org-1', canUseSearchMcp: true })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.fixture.test' })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ organization: { id: mocks.organizationId }, - viewer: { canUsePersonalApiKeys: false }, + viewer: { canUsePersonalApiKeys: false, canUseSearchMcp: mocks.canUseSearchMcp }, }), })) @@ -22,6 +22,7 @@ describe('Organization Search MCP', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.organizationId = 'org-1' + mocks.canUseSearchMcp = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -33,7 +34,7 @@ describe('Organization Search MCP', () => { vi.unstubAllGlobals() }) - it('offers organization OAuth setup even when personal API keys are disabled', async () => { + it('offers OAuth setup when Search MCP is allowed without API key management', async () => { await act(async () => root.render()) expect(container.querySelector('input')?.value).toBe( 'https://sim.fixture.test/api/mcp/search/organizations/org-1' @@ -44,6 +45,16 @@ describe('Organization Search MCP', () => { expect(container.textContent).not.toContain('Authorization header') }) + it('explains the organization policy restriction without offering OAuth setup', async () => { + mocks.canUseSearchMcp = false + await act(async () => root.render()) + expect(container.textContent).toContain('organization’s policy disables Sim Search MCP access') + expect(container.textContent).toContain('Contact an organization admin') + expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('[aria-label^="MCP app: "]')).toBeNull() + expect(container.textContent).not.toContain('sign in to Sim') + }) + it('replaces the connection scope when the organization changes', async () => { await act(async () => root.render()) mocks.organizationId = 'org-2' diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx index a0235437d6d..7607f4c93ed 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx @@ -5,7 +5,15 @@ import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organ import { SearchMcpConnection } from '@/app/o/[organizationId]/settings/components/search-mcp-connection' export function OrganizationSearchMcp() { - const { organization } = useOrganizationContext() + const { organization, viewer } = useOrganizationContext() + if (!viewer.canUseSearchMcp) { + return ( +

+ Your organization’s policy disables Sim Search MCP access. Contact an organization admin to + enable it. +

+ ) + } const endpoint = getSearchMcpUrl(organization.id) return ( diff --git a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx index c19fa601a15..1bcec157d2b 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx @@ -3,8 +3,8 @@ import { useState } from 'react' import { Chip, - ChipDropdown, ChipModalField, + ChipSelect, Code, chipFieldSurfaceClass, useCopyToClipboard, @@ -38,18 +38,20 @@ export function SearchMcpConnection({ endpoint }: SearchMcpConnectionProps) { return ( <> - { - const option = CLIENTS.find((item) => item.value === value) - if (option) setClient(option.value) - }} - options={CLIENTS} - aria-label={`MCP app: ${CLIENTS.find((option) => option.value === client)?.label}`} - align='start' - matchTriggerWidth={false} - className='self-start' - /> +
+ { + const option = CLIENTS.find((item) => item.value === value) + if (option) setClient(option.value) + }} + options={[...CLIENTS]} + aria-label={`MCP app: ${CLIENTS.find((option) => option.value === client)?.label}`} + align='start' + fullWidth + dropdownWidth='trigger' + /> +
{client !== 'cursor' ? ( { ) await click('Advanced') expect(container.textContent).toContain( - 'A sync configuration controls what gets indexed and how often.' + 'No accounts connected yet. A sync configuration will be created when someone connects.' ) expect(container.textContent).toContain('Add sync configuration') await click('Add sync configuration') diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index d1002eb31b2..eda0203eca3 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -221,12 +221,6 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const renderSources = () => ( - {automaticSetup && ( - - A sync configuration controls what gets indexed and how often. Connecting the first - account creates the default configuration automatically. - - )} {approval.error && ( {approval.error.message} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx index 97c9c683b3e..b339c1a91fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx @@ -16,6 +16,7 @@ import { import { BRAND_ICON_BY_BASE_TYPE, sourceLabel, + sourceSiteName, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { BrandIcon } from '@/blocks/brand-icon' @@ -133,7 +134,7 @@ export function SourceCard({ source, query, onSummarize, dense = false }: Source : undefined const updatedAt = parseUpdatedAt(source.updatedAt) const meta = [ - sourceLabel(source), + sourceSiteName(source), source.author?.trim() || null, updatedAt ? formatDate(updatedAt) : null, ].filter((part): part is string => Boolean(part)) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts index 329fa2848eb..612c5e37cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts @@ -1 +1,6 @@ -export { BRAND_ICON_BY_BASE_TYPE, SourceChip, sourceLabel } from './source-chip' +export { + BRAND_ICON_BY_BASE_TYPE, + SourceChip, + sourceLabel, + sourceSiteName, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx new file mode 100644 index 00000000000..fbba6a94034 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx @@ -0,0 +1,81 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/browser-agent/open-in-panel', () => ({ + shouldOpenInBrowserPanel: () => false, + openInBrowserPanel: vi.fn(), +})) +vi.mock('@/lib/integrations', () => ({ blockTypeToIconMap: {} })) + +import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card' +import { SourceChip } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' +import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(ui: React.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(ui)) + return container +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('citation labels', () => { + it.each([ + { + url: 'https://mail.google.com/mail/u/0/#all/thread', + title: 'Launch checklist', + siteName: 'Sim Search', + connectorType: 'gmail', + }, + { + url: 'https://example.slack.com/archives/channel/message', + title: '#engineering — release handoff', + siteName: 'Slack', + connectorType: 'slack', + }, + { + url: 'https://docs.github.com/page', + title: 'Managing repositories', + siteName: 'GitHub Docs', + }, + ])('uses the retrieved title for $url', (source: SourceTagData) => { + const view = mount() + expect(view.querySelector('a')?.textContent).toBe(source.title) + expect(view.querySelector('a')?.getAttribute('href')).toBe(source.url) + }) + + it.each([ + [{ url: 'https://mail.google.com/thread', title: ' ', siteName: 'Gmail' }, 'Gmail'], + [{ url: 'https://www.example.com/page' }, 'example.com'], + ] as const)('keeps a readable fallback without a title', (source, expected) => { + expect(mount().querySelector('a')?.textContent).toBe(expected) + }) + + it('keeps the provider separate from the source card title', () => { + const view = mount( + + ) + expect(view.querySelector('[data-source-link]')?.textContent).toBe('Launch checklist') + expect(view.textContent?.match(/Launch checklist/g)).toHaveLength(1) + expect(view.textContent).toContain('Gmail') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx index 75a276e46a2..8e3ae251d43 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx @@ -23,20 +23,25 @@ export const BRAND_ICON_BY_BASE_TYPE: ReadonlyMap = new M Object.entries(blockTypeToIconMap).map(([type, icon]) => [stripVersionSuffix(type), icon]) ) -/** Chip label: the site name the model supplied, else the URL's hostname without a `www.` prefix. */ -export function sourceLabel(source: SourceTagData): string { +/** The source's site or provider, separate from its document title. */ +export function sourceSiteName(source: SourceTagData): string { const siteName = source.siteName?.trim() if (siteName) return siteName return (externalLinkHostname(source.url) ?? source.url).replace(/^www\./, '') } +/** Citations identify the document; source metadata is the fallback when its title is unavailable. */ +export function sourceLabel(source: SourceTagData): string { + return source.title?.trim() || sourceSiteName(source) +} + interface SourceChipProps { source: SourceTagData } /** * A cited document as a small round pill — the connector's brand mark or the - * site favicon, then the site name — used inline at the citation point and + * site favicon, then the document title — used inline at the citation point and * again in the footer strip. Built on the chip fill and hover tokens at a 20px * height so it sits inside a line of prose; the 30px `Chip` is the wrong scale * for a citation. Opens the document like any external link in the reply. diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0330603d98c..9130d67c55e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -327,11 +327,11 @@ export interface WorkspaceResourceTagData { export interface SourceTagData { /** Canonical http(s) link to the referenced document. */ url: string - /** Document title, shown on hover. */ + /** Document title, used as the citation label and shown in full on hover. */ title?: string /** - * Short chip label — the site or product the document lives in ("GitHub - * Docs", "Confluence"). Falls back to the URL's hostname. + * The site or product the document lives in ("GitHub Docs", "Confluence"), + * used when its title is missing and as secondary metadata in source cards. */ siteName?: string /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts index f9cb6f66bbe..dc297b1df74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts @@ -67,6 +67,37 @@ describe('evidence-linked citations', () => { ).blocks[1].content ).toEqual(resolveMessageCitations(blocks(), '', true).blocks[1].content) }) + it('retains a retrieved provider label after persistence instead of the internal index name', () => { + const providerOutput = structuredClone(output) + Object.assign(providerOutput.data.results[0], { + knowledgeBaseName: 'Sim Search', + siteName: 'Gmail', + connectorType: 'gmail', + }) + for (const result of [ + providerOutput, + compactRetrievalCitations('search_workspace', providerOutput), + ]) { + const resolved = resolveMessageCitations(blocks(result), '', true).blocks[1].content + expect(resolved).toContain('"title":"Actual title"') + expect(resolved).toContain('"siteName":"Gmail"') + expect(resolved).not.toContain('Sim Search') + } + }) + + it('keeps the document title when a follow-up uses only read_document evidence', () => { + const readBlocks = blocks({ + success: true, + data: { + ...output.data.results[0], + chunks: [{ content: 'Retrieved passage', chunkIndex: 0 }], + }, + }) + readBlocks[0].toolCall!.name = 'read_document' + expect(resolveMessageCitations(readBlocks, '', true).blocks[1].content).toContain( + '"title":"Actual title"' + ) + }) it('resolves source tags split across streamed text chunks before rendering', () => { const split = blocks().slice(0, 1) split.push( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx new file mode 100644 index 00000000000..2f33ace6fd8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.test.tsx @@ -0,0 +1,133 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn() })) + +vi.mock('@sim/emcn', () => ({ + ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalHeader: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: ({ + type, + title, + value, + onChange, + }: { + type: string + title: string + value?: string + onChange: (value: string) => void + }) => + type === 'file' ? null : ( +