diff --git a/apps/docs/app/robots.txt/route.ts b/apps/docs/app/robots.txt/route.ts index c9f62b347f8..205397a3b1c 100644 --- a/apps/docs/app/robots.txt/route.ts +++ b/apps/docs/app/robots.txt/route.ts @@ -10,7 +10,6 @@ export async function GET() { User-agent: * Disallow: /.next/ Disallow: /api/internal/ -Disallow: /_next/static/ Disallow: /admin/ Allow: / Allow: /llms.txt diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index 342f0b733ab..8ed44b21d40 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -41,7 +41,7 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Contact', href: `${SIM_SITE_URL}/contact`, external: true }, ] -/** Top model providers — mirrors the landing footer's top 8 catalog providers. */ +/** Top model providers — mirrors the landing footer's top 7 catalog providers. */ const MODEL_LINKS: FooterItem[] = [ { label: 'All Models', href: `${SIM_SITE_URL}/models`, external: true }, { label: 'OpenAI', href: `${SIM_SITE_URL}/models/openai`, external: true }, @@ -51,7 +51,6 @@ const MODEL_LINKS: FooterItem[] = [ { label: 'xAI', href: `${SIM_SITE_URL}/models/xai`, external: true }, { label: 'Cerebras', href: `${SIM_SITE_URL}/models/cerebras`, external: true }, { label: 'Groq', href: `${SIM_SITE_URL}/models/groq`, external: true }, - { label: 'Sakana AI', href: `${SIM_SITE_URL}/models/sakana`, external: true }, ] const BLOCK_LINKS: FooterItem[] = [ @@ -84,7 +83,7 @@ const SOCIAL_LINKS: FooterItem[] = [ { label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true }, { label: 'LinkedIn', - href: 'https://www.linkedin.com/company/simstudioai/', + href: 'https://www.linkedin.com/company/simdotai/', external: true, }, { diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index f62a9765458..cee24c8b1c2 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -55,7 +55,7 @@ Read workspace file objects from selected files, canonical workspace file IDs, o ### File Get Content -Extract the text content of workspace files selected directly, identified by canonical file ID, or collected from one or more workspace folders. +Extract workspace file text using the same parser mode as File Search. Use the returned fileId and offset/limit to read the matching line and surrounding context. For documents and spreadsheets, line numbers refer to extracted text, not page numbers or worksheet row numbers. #### Input @@ -77,7 +77,7 @@ Extract the text content of workspace files selected directly, identified by can ### File Search -Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped or partial files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees. +Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees. #### Input @@ -99,13 +99,13 @@ Search the indexed text of active workspace files for lines matching a query, an | ↳ `text` | string | Matching line or bounded match-centered preview. | | `count` | number | Number of returned matching lines. | | `truncated` | boolean | Whether more matching lines exist beyond the configured hard cap. | -| `complete` | boolean | Whether indexing has no pending or failed current revisions; skipped and partial coverage is reported separately. | +| `complete` | boolean | Whether indexing has no pending or failed current revisions; excluded files are reported separately. | | `indexStatus` | object | Current workspace search-index coverage by file status. | -| ↳ `readyFiles` | number | Files whose current revision is searchable. | +| ↳ `readyFiles` | number | Files whose entire current extracted text is searchable. | | ↳ `pendingFiles` | number | Files still waiting to be indexed. | | ↳ `failedFiles` | number | Files whose current indexing attempt failed. | -| ↳ `skippedFiles` | number | Files intentionally excluded because they are unsupported or oversized. | -| ↳ `partialFiles` | number | Searchable files whose extracted text was truncated by the parser or cap. | +| ↳ `skippedFiles` | number | Files excluded in full because they are oversized, unsupported, or cannot be completely extracted. | +| ↳ `partialFiles` | number | Always zero; retained for compatibility. Files are never partially indexed. | ### File Fetch @@ -380,4 +380,16 @@ Move an existing workspace file into a folder. Moves the file itself; use Move F | `fileId` | string | The file that was moved. | | `folderPath` | string | The folder the file now lives in. | +{/* MANUAL-CONTENT-START:search_limits */} +## Search coverage and limits +Search indexes the complete extracted text of each eligible file. The source file and its extracted UTF-8 text must each be at most **25 MiB (26,214,400 bytes)**. Oversized files, unsupported binary formats, and documents that cannot be completely extracted within parser safety limits are excluded as whole files and counted in `skippedFiles`. Search never indexes only the first rows, lines, or characters. CSV search preserves decoded source text; spreadsheet search includes populated cells beyond the preview limits. Image-only documents require searchable text; search does not perform OCR. + +Existing parser safeguards also apply to complete extraction. PDFs allow at most 10,000 pages, 20 MiB of extracted text, 250,000 characters on one page, and 60 seconds of extraction. Office archives allow at most 150 MiB expanded in total, 64 MiB for one archive entry, and 10,000 entries; malformed archives and excessive compression ratios are rejected. Hitting any of these limits excludes the whole file from search. + +Updates are indexed asynchronously. `pendingFiles` and `failedFiles` indicate revisions that are not yet searchable; a new revision becomes searchable only when its full index is ready. An empty result proves absence only within the searched scope when `complete` is true and `skippedFiles` is zero. + +Regex is evaluated against complete logical lines, including long lines, and cannot span line breaks. Returned lines may use a shortened preview. The result limit (up to 200 lines) and a 10-second query deadline limit an individual request, not the amount of text indexed. An expensive query fails explicitly instead of returning an apparently complete subset; narrow its literal text or folder scope and retry. + +Search returns one result per matching logical line with `fileId`, 1-based `lineNumber`, and `text` (a bounded preview for long lines). An Agent can use **Search** to locate content, then **Get Content** with the returned `fileId`, `offset` near `lineNumber`, and a small `limit` to read surrounding context. These operations use the same complete-text parser mode. Line numbers for documents and spreadsheets refer to extracted text, not page numbers or worksheet row numbers. Re-run search if the file changes between calls. +{/* MANUAL-CONTENT-END */} diff --git a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx index f5a0bacbedc..f28ee54d6de 100644 --- a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx +++ b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx @@ -48,6 +48,7 @@ export function CoreFeatureCard({ const graphic = (
{ root = null host?.remove() host = null + vi.unstubAllGlobals() }) function mount(strict = false): HTMLElement { @@ -106,6 +107,34 @@ describe('foldScrollLeft', () => { }) describe('FeaturesRail', () => { + it('waits until the rail approaches the viewport before adding the loop copies', () => { + let notify: IntersectionObserverCallback | undefined + const disconnect = vi.fn() + const observe = vi.fn() + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + notify = callback + } + observe = observe + disconnect = disconnect + } + ) + const rail = mount() + expect(observe).toHaveBeenCalledWith(rail) + expect(rail.children).toHaveLength(3) + const observer = {} as IntersectionObserver + act(() => notify?.([{ isIntersecting: false } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(3) + act(() => notify?.([{ isIntersecting: true } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(9) + expect(rail.scrollLeft).toBe(SET) + expect(disconnect).toHaveBeenCalledOnce() + act(() => notify?.([{ isIntersecting: false } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(9) + }) + it('server-renders the finite rail once, with the scroll chrome', () => { const html = renderToStaticMarkup( {cards()} diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx index c2bcf2058dc..93ef0f0bd67 100644 --- a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx +++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx @@ -86,8 +86,8 @@ interface FeaturesRailProps { /** Accessible name of the scrolling region. */ label: string /** - * The cards, in order. Each becomes one slot; once JS runs the whole set is - * cloned on both sides so the rail loops. + * The cards, in order. Each becomes one slot; as the rail approaches the + * viewport the whole set is cloned on both sides so the rail loops. */ children: ReactNode } @@ -96,7 +96,7 @@ interface FeaturesRailProps { * The homepage product rail: native horizontal scrolling that never ends. * * The server renders the set once, so the HTML - and any visit without JS - is - * the plain finite rail with the first card under the heading. After hydration + * the plain finite rail with the first card under the heading. Near the viewport * the set is cloned once on each side, the scroll position jumps one set width * before paint so nothing visibly moves (folded, so Strict Mode's second run of * the effect lands on the same spot), and a passive scroll listener folds the @@ -124,7 +124,22 @@ export function FeaturesRail({ label, children }: FeaturesRailProps) { const cards = Children.toArray(children) useEffect(() => { - setLooping(true) + const rail = railRef.current + if (!rail) return + if (typeof IntersectionObserver === 'undefined') { + setLooping(true) + return + } + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setLooping(true) + observer.disconnect() + }, + { rootMargin: '600px' } + ) + observer.observe(rail) + return () => observer.disconnect() }, []) useLayoutEffect(() => { diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 44be284c0d4..4d8d7fae2bc 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -87,7 +87,7 @@ const RESOURCES_LINKS: FooterItem[] = [ /** Top model providers, sourced from the catalog so labels/hrefs never drift. */ const MODEL_LINKS: FooterItem[] = [ { label: 'All Models', href: '/models' }, - ...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map((provider) => ({ + ...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 7).map((provider) => ({ label: provider.name, href: provider.href, })), @@ -119,7 +119,7 @@ const SOCIAL_LINKS: FooterItem[] = [ { label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true }, { label: 'LinkedIn', - href: 'https://www.linkedin.com/company/simstudioai/', + href: 'https://www.linkedin.com/company/simdotai/', external: true, }, { diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx index b142adc61ce..54e972887d8 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx @@ -75,20 +75,20 @@ const EMPTY_IDS: ReadonlySet = new Set() const ACTION_BUTTON_STYLES = [ 'size-[24px] rounded-md p-0', 'border-none bg-transparent text-[var(--text-icon)]', - 'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]', - 'dark:hover-hover:bg-[var(--surface-4)]', - 'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]', + 'transition-[background-color,color,opacity,transform] duration-150', 'group-data-[node-selected]:text-[var(--surface-2)]', - 'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]', - 'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]', ].join(' ') const FIRST_ACTION_STYLES = "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&>svg]:translate-x-[8px] [&>svg]:translate-y-px" +/** A 24px target even at MIN_ZOOM, extending above/left of the unchanged 40px painted slot. */ +const RUN_ACTION_HIT_STYLES = + 'group/run relative -ml-[14px] size-[54px] shrink-0 border-none bg-transparent! p-0' + /** The running run slot: graphite fill, inverse glyph - the editor's own treatment. */ const RUNNING_RUN_STYLES = - '!bg-[var(--text-secondary)] !text-[var(--text-inverse)] hover-hover:!bg-[var(--white)] hover-hover:!text-[var(--surface-inverted)]' + '!bg-[var(--text-secondary)] !text-[var(--text-inverse)] group-hover-hover/run:!bg-[var(--white)] group-hover-hover/run:!text-[var(--surface-inverted)]' /** A bystander card's actions dim mid-run; the run/stop slot keeps its ordinary chrome. */ const BYSTANDER_ACTION_STYLES = '!bg-transparent !opacity-25 hover-hover:!bg-transparent dark:hover-hover:!bg-transparent' @@ -227,7 +227,7 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev return (
{sweeping && ( @@ -250,35 +250,42 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev )} - + @@ -288,23 +295,22 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev {inertActions.map(({ label, Icon }) => ( - + + + {!workflowRunning && {label}} diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx index c2523b051cb..37ea2107d22 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx @@ -27,6 +27,13 @@ vi.mock('next/link', () => ({ ), })) +vi.mock('next/dynamic', () => ({ + default: + () => + ({ item }: { item: NavMenuItemData }) => ( + {item.preview.kind} + ), +})) vi.mock('@/app/(landing)/components/chevron-arrow', () => ({ ChevronArrow: () => null, })) @@ -104,6 +111,17 @@ function expectSelected(href: string, kind: string) { } describe('NavMenuCluster feature selection', () => { + it('mounts the preview on first opening and preserves it during the exit transition', () => { + expect(host.querySelector('output')).toBeNull() + hover(element('#nav-platform-menu-trigger')) + expect(host.querySelector('output')).not.toBeNull() + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(element('#primary-navigation-mega-menu').getAttribute('aria-hidden')).toBe('true') + expect(host.querySelector('output')).not.toBeNull() + }) + it('prefetches destinations only while their menu is open', () => { const overview = element('a[href="/platform"]') const customers = element('#nav-customers-menu a[href="/customers/rivian"]') diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx index 80afd4e6fc2..ce6f699f547 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react' import { ChipChevronDown, chipContentLabelClass, chipVariants, cn } from '@sim/emcn' +import dynamic from 'next/dynamic' import { flushSync } from 'react-dom' import { HOME_INSET, @@ -11,11 +12,18 @@ import { import { NavMenuCard } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-card' import { NavMenuItem } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-item' import { NavMenuLogoMarquee } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee' -import { NavMenuPreview } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/nav-menu-preview' import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-menu-chip/types' import { NAVBAR_GLASS_SURFACE } from '@/app/(landing)/components/navbar/components/navbar-shell' import { useNavbarMenu } from '@/app/(landing)/components/navbar/hooks/use-navbar-menu' +const NavMenuPreview = dynamic( + () => + import( + '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/nav-menu-preview' + ).then((module) => module.NavMenuPreview), + { loading: () =>
} +) + interface NavMenuClusterProps { /** Non-empty group of mega-menus that share one stable panel. */ menus: readonly [NavMenu, ...NavMenu[]] @@ -68,6 +76,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { () => menus.find((menu) => !isFloating(menu)) ?? menus[0] ) const [activeItem, setActiveItem] = useState(surfaceMenu.sections[0].items[0]) + const [previewMounted, setPreviewMounted] = useState(false) useEffect(() => { if (!open) return @@ -86,6 +95,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { const activateMenu = (menu: NavMenu) => { setActiveMenu(menu) if (!isFloating(menu)) { + setPreviewMounted(true) setSurfaceMenu(menu) setActiveItem(menu.sections[0].items[0]) } @@ -270,7 +280,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { ))}
- + {previewMounted && }
diff --git a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx index a4b8fa24914..591920d098e 100644 --- a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx +++ b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx @@ -36,7 +36,7 @@ const SITE_JSON_LD = { sameAs: [ 'https://x.com/simdotai', 'https://github.com/simstudioai/sim', - 'https://www.linkedin.com/company/simstudioai/', + 'https://www.linkedin.com/company/simdotai/', 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA', ], contactPoint: [ diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index 4da00f38e27..563b7b7f9f3 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -44,7 +44,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', - lastUpdated: 'September 3, 2026', + lastUpdated: 'September 17, 2026', intro: [ { kind: 'paragraph', @@ -165,7 +165,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { [ '__cf_bm', 'Cloudflare', - 'Bot-management check on requests to providers we load, such as HubSpot and X.', + 'Bot-management check on requests to providers we load, such as X.', '30 minutes', ], ]), @@ -177,10 +177,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { 'Holds the session state for a specific Analytics property.', '13 months', ], - ['__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months'], - ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], - ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], - ['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'], [ 'ph_*_posthog', 'PostHog', @@ -269,9 +265,8 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { Google Analytics , Google Ads,{' '} - X (Twitter),{' '} - HubSpot, and{' '} - PostHog. + X (Twitter), + and PostHog. ), }, @@ -292,7 +287,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { The providers currently in use are{' '} Google{' '} (Analytics and Ads),{' '} - HubSpot,{' '} X (Twitter),{' '} Ahrefs,{' '} PostHog, and{' '} diff --git a/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx b/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx index ad1d78fd5b5..cff20bc4ebd 100644 --- a/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx +++ b/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx @@ -84,7 +84,7 @@ export function InteractiveLibraryFolder({ + + + ), + ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: () => null, + ChipModalError: ({ children }: { children: ReactNode }) => + children ?

{children}

: null, + ChipModalFooter: ({ + onCancel, + primaryAction, + }: { + onCancel: () => void + primaryAction: { label: string; onClick: () => void } + }) => ( + <> + + + + ), + ChipConfirmModal: ({ + open, + children, + onOpenChange, + confirm, + }: { + open: boolean + children: ReactNode + onOpenChange: (open: boolean) => void + confirm: { label: string; onClick: () => void } + }) => + open ? ( +
+ {children} + + +
+ ) : null, +})) + +import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle' + +let container: HTMLDivElement +let root: Root +let client: QueryClient +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.enabled = false + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) +}) +afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() + vi.useRealTimers() +}) + +function render() { + act(() => + root.render( + + + + ) + ) +} +async function click(label: string) { + const button = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === label + ) + expect(button).toBeDefined() + await act(async () => { + button?.click() + await vi.runAllTimersAsync() + }) +} + +describe('inbox setup error visibility', () => { + it.each([ + { enabled: false, toggle: 'On', submit: 'Enable' }, + { enabled: true, toggle: 'Off', submit: 'Disable inbox' }, + ])( + 'keeps the dialog open and displays a failed $submit request', + async ({ enabled, toggle, submit }) => { + mocks.enabled = enabled + mocks.toggle.mockRejectedValueOnce(new Error('Email service unavailable')) + render() + await click(toggle) + await click(submit) + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Email service unavailable' + ) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + await click('Cancel') + await click(toggle) + expect(container.querySelector('[role="alert"]')).toBeNull() + } + ) + + it('clears the failure and closes after a successful retry', async () => { + mocks.toggle + .mockRejectedValueOnce(new Error('Email service unavailable')) + .mockResolvedValueOnce({ enabled: true }) + render() + await click('On') + await click('Enable') + expect(container.querySelector('[role="alert"]')).not.toBeNull() + await click('Enable') + expect(container.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.toggle).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx index 0159c6ac807..41c87b24cf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx @@ -1,21 +1,25 @@ 'use client' -import { useCallback, useState } from 'react' +import { useState } from 'react' import { ChipConfirmModal, ChipModal, ChipModalBody, + ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, + ChipSwitch, Label, - Switch, } from '@sim/emcn' -import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox' -const logger = createLogger('InboxEnableToggle') +const INBOX_OPTIONS = [ + { value: 'enabled', label: 'On' }, + { value: 'disabled', label: 'Off' }, +] as const export function InboxEnableToggle() { const params = useParams() @@ -28,56 +32,68 @@ export function InboxEnableToggle() { const [isDisableOpen, setIsDisableOpen] = useState(false) const [enableUsername, setEnableUsername] = useState('') - const handleToggle = useCallback(async (checked: boolean) => { + function handleToggle(checked: boolean) { + toggleInbox.reset() if (checked) { setIsEnableOpen(true) - return + } else { + setIsDisableOpen(true) } - setIsDisableOpen(true) - }, []) + } - const handleDisable = useCallback(async () => { - try { - await toggleInbox.mutateAsync({ workspaceId, enabled: false }) - setIsDisableOpen(false) - } catch (error) { - logger.error('Failed to disable inbox', { error }) - } - }, [workspaceId, toggleInbox.mutateAsync]) + function handleEnableOpenChange(open: boolean) { + if (!toggleInbox.isPending) setIsEnableOpen(open) + } - const handleEnable = useCallback(async () => { - try { - await toggleInbox.mutateAsync({ - workspaceId, - enabled: true, - username: enableUsername.trim() || undefined, - }) - setIsEnableOpen(false) - setEnableUsername('') - } catch (error) { - logger.error('Failed to enable inbox', { error }) - } - }, [workspaceId, enableUsername, toggleInbox.mutateAsync]) + function handleDisable() { + toggleInbox.mutate( + { workspaceId, enabled: false }, + { onSuccess: () => setIsDisableOpen(false) } + ) + } + + function handleEnable() { + toggleInbox.mutate( + { workspaceId, enabled: true, username: enableUsername.trim() || undefined }, + { + onSuccess: () => { + setIsEnableOpen(false) + setEnableUsername('') + }, + } + ) + } + + const error = toggleInbox.error + ? getErrorMessage(toggleInbox.error, 'Failed to update inbox') + : null return ( <>
- +

Allow this workspace to receive tasks via email

- handleToggle(value === 'enabled')} disabled={toggleInbox.isPending} />
- - setIsEnableOpen(false)}>Enable email inbox + + handleEnableOpenChange(false)}> + Enable email inbox +

An email address will be created for this workspace. Anyone in the allowed senders list @@ -93,11 +109,13 @@ export function InboxEnableToggle() {

Leave blank for an auto-generated address.

+ {error}
setIsEnableOpen(false)} + onCancel={() => handleEnableOpenChange(false)} + cancelDisabled={toggleInbox.isPending} primaryAction={{ - label: 'Enable', + label: toggleInbox.isPending ? 'Enabling...' : 'Enable', onClick: handleEnable, disabled: toggleInbox.isPending, }} @@ -125,6 +143,7 @@ export function InboxEnableToggle() {

Your existing conversations and task history will be preserved.

+ {error} ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index 894a55ef643..b6274cde49d 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -296,6 +296,11 @@ export function InboxSettingsTab() { )} + {updateSecretPolicy.error && ( +

+ {getErrorMessage(updateSecretPolicy.error, 'Failed to update secret access')} +

+ )} diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts index 2623409ec68..b200f9a0dbd 100644 --- a/apps/sim/background/knowledge-processing.test.ts +++ b/apps/sim/background/knowledge-processing.test.ts @@ -34,7 +34,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ })) import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error' -import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' +import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit' import { OcrRequestRejectedError, diff --git a/apps/sim/lib/billing/core/inbox-entitlement.test.ts b/apps/sim/lib/billing/core/inbox-entitlement.test.ts new file mode 100644 index 00000000000..38073f0b69e --- /dev/null +++ b/apps/sim/lib/billing/core/inbox-entitlement.test.ts @@ -0,0 +1,203 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + resetDbChainMock, + resetEnvFlagsMock, + resetEnvMock, + setEnv, + setEnvFlags, +} from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetPersonalSubscription, + mockGetOrganizationSubscription, + mockGetWorkspaceWithOwner, + mockGetEffectiveBillingStatus, + mockIsOrganizationBillingBlocked, +} = vi.hoisted(() => ({ + mockGetPersonalSubscription: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), + mockGetEffectiveBillingStatus: vi.fn(), + mockIsOrganizationBillingBlocked: vi.fn(), +})) + +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, + getHighestPrioritySubscription: vi.fn(), +})) + +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) + +vi.mock('@/lib/billing/core/access', () => ({ + getEffectiveBillingStatus: mockGetEffectiveBillingStatus, + isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mockGetWorkspaceWithOwner, +})) + +import { + hasWorkspaceInboxAccess, + hasWorkspaceInboxGraceAccess, +} from '@/lib/billing/core/subscription' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + setEnv({ COPILOT_API_KEY: 'test-copilot-key' }) + setEnvFlags({ isHosted: true, isBillingEnabled: true, isInboxEnabled: false }) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: null, + }) + mockGetPersonalSubscription.mockResolvedValue(null) + mockGetOrganizationSubscription.mockResolvedValue(null) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) + mockIsOrganizationBillingBlocked.mockResolvedValue(false) +}) + +afterEach(() => { + resetEnvFlagsMock() + resetEnvMock() +}) + +describe('Sim Mailer hosted entitlement', () => { + it.each([ + { isInboxEnabled: true, isBillingEnabled: true }, + { isInboxEnabled: false, isBillingEnabled: false }, + { isInboxEnabled: true, isBillingEnabled: false }, + ])('requires a qualifying payer despite deployment flags %o', async (flags) => { + setEnvFlags(flags) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(false) + expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1') + }) + + it.each([ + ['pro_25000', true], + ['enterprise', true], + ['pro_6000', false], + ['pro', false], + ['free', false], + ])('checks the personal workspace payer plan %s', async (plan, expected) => { + mockGetPersonalSubscription.mockResolvedValue({ plan, status: 'active' }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected) + }) + + it.each([ + ['team_25000', true], + ['enterprise', true], + ['team_6000', false], + ])( + 'checks the organization payer plan %s without requiring a personal plan', + async (plan, expected) => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: 'org-1', + }) + dbChainMockFns.limit.mockResolvedValueOnce([{ plan, status: 'active' }]) + mockGetOrganizationSubscription.mockResolvedValue({ plan, status: 'active' }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected) + expect(mockGetPersonalSubscription).not.toHaveBeenCalled() + expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' }) + } + ) + + it('blocks a past-due Max payer from use while preserving provisioned resources', async () => { + mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'past_due' }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) + + it('blocks a billing-blocked Max payer from use while preserving provisioned resources', async () => { + mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' }) + mockGetEffectiveBillingStatus.mockResolvedValue({ billingBlocked: true }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) + + it('requires the execution key for use without destroying resources when it is missing', async () => { + setEnv({ COPILOT_API_KEY: undefined }) + mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) +}) + +describe('Sim Mailer cleanup uncertainty', () => { + it('preserves the inbox if the workspace cannot be found', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue(null) + + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) + + it('preserves the inbox on a workspace lookup failure', async () => { + mockGetWorkspaceWithOwner.mockRejectedValue(new Error('Database unavailable')) + + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) + + it('requires the personal subscription reader to surface errors and preserves the inbox', async () => { + mockGetPersonalSubscription.mockRejectedValue(new Error('Database unavailable')) + + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1', { onError: 'throw' }) + }) + + it('requires the organization subscription reader to surface errors and preserves the inbox', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: 'org-1', + }) + mockGetOrganizationSubscription.mockRejectedValue(new Error('Database unavailable')) + + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' }) + }) + + it('retains past-due Max for Teams resources', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + billedAccountUserId: 'payer-1', + organizationId: 'org-1', + }) + mockGetOrganizationSubscription.mockResolvedValue({ plan: 'team_25000', status: 'past_due' }) + + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + }) +}) + +describe('Sim Mailer self-hosted overrides', () => { + it.each([ + { isInboxEnabled: true, isBillingEnabled: true }, + { isInboxEnabled: false, isBillingEnabled: false }, + ])('preserves self-hosted configuration %o', async (flags) => { + setEnvFlags({ isHosted: false, ...flags }) + + await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(true) + await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true) + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 10b709b54bd..64c6b0c8f6d 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -803,16 +803,14 @@ const hasMaxTierWorkspaceAccess = cache( * Inbox. * * Otherwise returns true if: - * - INBOX_ENABLED env var is set (self-hosted override), OR - * - billing is disabled, OR + * - on self-hosted deployments, INBOX_ENABLED is set or billing is disabled, OR * - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR * - the billed user has an individual Max/enterprise subscription (personal workspace). */ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { if (!env.COPILOT_API_KEY) return false - if (isInboxEnabled) return true - if (!isBillingEnabled) return true + if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true return await hasMaxTierWorkspaceAccess(workspaceId) } catch (error) { logger.error('Error checking workspace inbox access', { error, workspaceId }) @@ -834,12 +832,12 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { - if (isInboxEnabled) return true - if (!isBillingEnabled) return true + if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true return await hasWorkspaceTierAccess(workspaceId, isMaxTier, { intent: 'retention', onMissingWorkspace: true, + onError: 'throw', }) } catch (error) { logger.error('Error checking workspace inbox grace access', { error, workspaceId }) diff --git a/apps/sim/lib/consent/scripts.test.ts b/apps/sim/lib/consent/scripts.test.ts index 48477954a39..a43c11045f7 100644 --- a/apps/sim/lib/consent/scripts.test.ts +++ b/apps/sim/lib/consent/scripts.test.ts @@ -7,7 +7,6 @@ import { GLOBAL_CONSENT_SCRIPTS, GOOGLE_ADS_ID, GOOGLE_ANALYTICS_ID, - HUBSPOT_SCRIPT, X_PIXEL_SCRIPT, } from '@/lib/consent/scripts' @@ -27,7 +26,6 @@ const CALLBACK_INFO: ConsentScriptCallbackInfo = { afterEach(() => { window.dataLayer = [] window.gtag = undefined - window._hsq = [] window.history.replaceState({}, '', '/') }) @@ -49,8 +47,7 @@ describe('consent scripts', () => { ]) }) - it('keeps landing vendors in separate consent categories', () => { - expect(HUBSPOT_SCRIPT).toMatchObject({ id: 'hubspot', category: 'measurement' }) + it('gates the landing conversion pixel on marketing consent', () => { expect(X_PIXEL_SCRIPT).toMatchObject({ id: 'x-pixel', category: 'marketing', @@ -86,13 +83,4 @@ describe('consent scripts', () => { `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ADS_ID}` ) }) - - it('gives HubSpot a query-free path before its automatic first page view', () => { - window.history.replaceState({}, '', '/demo?email=private@example.com#booking') - window._hsq = [] - - HUBSPOT_SCRIPT.onBeforeLoad() - - expect(window._hsq).toEqual([['setPath', '/demo']]) - }) }) diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts index c431bec29cc..b797f580a56 100644 --- a/apps/sim/lib/consent/scripts.ts +++ b/apps/sim/lib/consent/scripts.ts @@ -23,12 +23,6 @@ export const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn' as const const AHREFS_ANALYTICS_KEY = 'WJ9yWTBAiQKZAE/2TyU/yA' as const -declare global { - interface Window { - _hsq?: unknown[][] - } -} - const GOOGLE_ANALYTICS_SCRIPT = gtag({ id: GOOGLE_ANALYTICS_ID, category: 'measurement', @@ -70,15 +64,3 @@ export const GLOBAL_CONSENT_SCRIPTS = [ /** Marketing-page integrations that should not load on a direct workspace visit. */ export const X_PIXEL_SCRIPT = xPixel({ pixelId: X_PIXEL_ID }) - -/** HubSpot has no first-party c15t helper, so it uses the generic script contract. */ -export const HUBSPOT_SCRIPT = { - id: 'hubspot', - src: 'https://js-na2.hs-scripts.com/246720681.js', - category: 'measurement', - async: true, - onBeforeLoad: () => { - window._hsq ||= [] - window._hsq.push(['setPath', window.location.pathname]) - }, -} as const diff --git a/apps/sim/lib/content/seo.ts b/apps/sim/lib/content/seo.ts index 0dea5ea335e..61e407461f6 100644 --- a/apps/sim/lib/content/seo.ts +++ b/apps/sim/lib/content/seo.ts @@ -242,7 +242,7 @@ export function buildTagsMetadata(section: ContentSection): Metadata { const canonical = `${SITE_URL}${section.basePath}/tags` const description = `Browse Sim ${section.name.toLowerCase()} posts by topic: AI agents, workflows, integrations, and more.` return { - title: 'Tags', + title: `${section.name} Tags`, description, alternates: { canonical }, openGraph: { diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 525be693b82..41900a665ab 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' import { + type CompiledDocReadOptions, loadCompiledDoc, loadPublishedCompiledDoc, publishCompiledDocArtifact, @@ -736,7 +737,7 @@ export async function loadCompiledDocByExt( workspaceId: string, source: string, ext: string, - options: { + options: CompiledDocReadOptions & { allowLegacyReferencedArtifact?: boolean allowPublishedReferencedArtifact?: boolean filePrincipal?: Principal @@ -744,18 +745,24 @@ export async function loadCompiledDocByExt( ): Promise<{ buffer: Buffer; contentType: string } | null> { const fmt = await getE2BDocFormat(`x.${ext}`) if (!fmt) return null + const readOptions: CompiledDocReadOptions = { maxBytes: options.maxBytes, signal: options.signal } const referencedFileIds = collectReferencedFileIds(source) if (!options.filePrincipal) { if (referencedFileIds.size === 0) { - const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions) return buffer ? { buffer, contentType: fmt.contentType } : null } if (options.allowPublishedReferencedArtifact) { - const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext) + const publishedBuffer = await loadPublishedCompiledDoc( + workspaceId, + source, + fmt.ext, + readOptions + ) if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType } } if (!options.allowLegacyReferencedArtifact) return null - const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions) return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null } const referencedImages = await resolveReferencedImages( @@ -768,11 +775,12 @@ export async function loadCompiledDocByExt( workspaceId, source, fmt.ext, - referencedImages.artifactIdentity + referencedImages.artifactIdentity, + readOptions ) if (buffer) return { buffer, contentType: fmt.contentType } if (referencedImages.artifactIdentity && options.allowLegacyReferencedArtifact) { - const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions) if (legacyBuffer) return { buffer: legacyBuffer, contentType: fmt.contentType } } return null @@ -799,7 +807,8 @@ export type ServableDoc = export async function resolveServableDoc( workspaceId: string, storedBytes: Buffer, - fileName: string + fileName: string, + options: CompiledDocReadOptions = {} ): Promise { const fmt = await getE2BDocFormat(fileName) if (!fmt) return { kind: 'passthrough' } @@ -810,7 +819,7 @@ export async function resolveServableDoc( workspaceId, storedBytes.toString('utf-8'), fmt.ext, - { allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true } + { ...options, allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true } ) return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts index 5fbaa13dbfb..c7a1ad258e8 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts @@ -3,19 +3,18 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDownloadFile, mockHeadObject, mockUploadFile } = vi.hoisted(() => ({ +const { mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({ mockDownloadFile: vi.fn(), - mockHeadObject: vi.fn(), mockUploadFile: vi.fn(), })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile, - headObject: mockHeadObject, uploadFile: mockUploadFile, })) import { + loadCompiledDoc, loadPublishedCompiledDoc, storeCompiledDoc, } from '@/lib/copilot/tools/server/files/doc-compiled-store' @@ -25,7 +24,10 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' describe('compiled document publication', () => { beforeEach(() => { vi.clearAllMocks() - mockHeadObject.mockResolvedValue(null) + mockDownloadFile.mockReset() + mockDownloadFile.mockRejectedValue( + Object.assign(new Error('Missing object'), { code: 'NoSuchKey' }) + ) }) it('publishes a source-keyed pointer after storing a dependency-bound artifact', async () => { @@ -53,7 +55,6 @@ describe('compiled document publication', () => { }) it('loads only the exact dependency-bound artifact named by the published pointer', async () => { - mockHeadObject.mockResolvedValue({ size: 1 }) mockDownloadFile .mockResolvedValueOnce( Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })) @@ -70,7 +71,6 @@ describe('compiled document publication', () => { }) it('bounds the artifact read so an oversized artifact is never materialized', async () => { - mockHeadObject.mockResolvedValue({ size: 1 }) mockDownloadFile.mockResolvedValueOnce( Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })) ) @@ -88,7 +88,6 @@ describe('compiled document publication', () => { it('surfaces an oversized artifact instead of reporting it as not yet built', async () => { // `null` means "still compiling", which callers answer with a retry — an artifact // that is too large would sit behind that answer forever. - mockHeadObject.mockResolvedValue({ size: 1 }) mockDownloadFile.mockResolvedValueOnce( Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })) ) @@ -105,8 +104,61 @@ describe('compiled document publication', () => { ) }) + it('applies the caller budget and cancellation to both pointer and artifact downloads', async () => { + const signal = new AbortController().signal + const maxBytes = 25 * 1024 * 1024 + mockDownloadFile + .mockResolvedValueOnce( + Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'x'.repeat(8192) })) + ) + .mockResolvedValueOnce(Buffer.from('%PDF-artifact')) + + await loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', { maxBytes, signal }) + + expect(mockDownloadFile).toHaveBeenCalledTimes(2) + for (const [options] of mockDownloadFile.mock.calls) { + expect(options).toMatchObject({ maxBytes, signal }) + } + }) + + it('cancels a pointer read without an uncancellable metadata preflight', async () => { + const controller = new AbortController() + mockDownloadFile.mockImplementationOnce(async ({ signal }) => { + expect(signal).toBe(controller.signal) + controller.abort() + signal.throwIfAborted() + }) + await expect( + loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', { + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it.each(['NoSuchKey', 'BlobNotFound', 'ENOENT', 404])( + 'returns null for a missing pointer (%s)', + async (code) => { + mockDownloadFile.mockRejectedValueOnce(Object.assign(new Error('Missing'), { code })) + await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).resolves.toBeNull() + } + ) + it('propagates pointer permission errors', async () => { + mockDownloadFile.mockRejectedValueOnce(new Error('Access denied')) + await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow( + 'Access denied' + ) + }) + it('does not turn an interrupted artifact download into a cache miss', async () => { + const controller = new AbortController() + mockDownloadFile.mockImplementationOnce(async () => { + controller.abort() + throw new Error('download interrupted') + }) + await expect( + loadCompiledDoc('workspace-1', 'source', 'pdf', undefined, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('still reports a missing artifact as not yet built', async () => { - mockHeadObject.mockResolvedValue({ size: 1 }) mockDownloadFile.mockResolvedValueOnce( Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' })) ) @@ -118,7 +170,6 @@ describe('compiled document publication', () => { }) it('fails fast on a malformed published pointer', async () => { - mockHeadObject.mockResolvedValue({ size: 1 }) mockDownloadFile.mockResolvedValueOnce(Buffer.from('{not-json')) await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow( diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts index a35cb3f10b2..e98342a01e1 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts @@ -2,7 +2,8 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' const logger = createLogger('CopilotDocCompiledStore') @@ -37,15 +38,37 @@ function publishedArtifactPointerKey(workspaceId: string, source: string, ext: s return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json` } +export interface CompiledDocReadOptions { + maxBytes?: number + signal?: AbortSignal +} + interface PublishedArtifactPointer { version: 1 referencedInputIdentity: string } -async function loadPublishedArtifactPointer(key: string): Promise { - const stored = await headObject(key, 'copilot') - if (!stored) return null - const encoded = await downloadFile({ key, context: 'copilot' }) +async function loadPublishedArtifactPointer( + key: string, + options: CompiledDocReadOptions = {} +): Promise { + options.signal?.throwIfAborted() + let encoded: Buffer + try { + encoded = await downloadFile({ + key, + context: 'copilot', + maxBytes: Math.min( + options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + MAX_BUFFERED_TRANSFER_BYTES + ), + signal: options.signal, + }) + } catch (error) { + options.signal?.throwIfAborted() + if (isObjectNotFoundError(error)) return null + throw error + } let decoded: unknown try { @@ -75,11 +98,8 @@ async function loadPublishedArtifactPointer(key: string): Promise { const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity) try { - return await downloadFile({ key, context: 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES }) + return await downloadFile({ + key, + context: 'copilot', + maxBytes: Math.min( + options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + MAX_BUFFERED_TRANSFER_BYTES + ), + signal: options.signal, + }) } catch (error) { + options.signal?.throwIfAborted() if (isPayloadSizeLimitError(error)) throw error return null } @@ -140,12 +170,19 @@ export async function publishCompiledDocArtifact( export async function loadPublishedCompiledDoc( workspaceId: string, source: string, - ext: string + ext: string, + options: CompiledDocReadOptions = {} ): Promise { const key = publishedArtifactPointerKey(workspaceId, source, ext) - const pointer = await loadPublishedArtifactPointer(key) + const pointer = await loadPublishedArtifactPointer(key, options) if (!pointer) return null - const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity) + const artifact = await loadCompiledDoc( + workspaceId, + source, + ext, + pointer.referencedInputIdentity, + options + ) if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`) return artifact } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts index 336ebe43491..87432887d62 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts @@ -96,7 +96,9 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).toHaveBeenCalledWith( WORKSPACE_ID, PDF_SOURCE.toString('utf-8'), - 'pdf' + 'pdf', + undefined, + { maxBytes: undefined, signal: undefined } ) expect(mockLoadCompiledDoc).toHaveBeenCalledTimes(1) }) @@ -123,7 +125,9 @@ describe('resolveServableDocBytes', () => { expect(mockLoadCompiledDoc).toHaveBeenCalledWith( WORKSPACE_ID, PDF_SOURCE.toString('utf-8'), - 'pdf' + 'pdf', + undefined, + { maxBytes: undefined, signal: undefined } ) }) @@ -202,7 +206,13 @@ describe('resolveServableDocBytes', () => { buffer: legacyArtifact, contentType: 'application/pdf', }) - expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf') + expect(mockLoadCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + undefined, + { maxBytes: undefined, signal: undefined } + ) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() expect(mockExecuteInSandbox).not.toHaveBeenCalled() expect(mockStoreCompiledDoc).not.toHaveBeenCalled() @@ -221,7 +231,8 @@ describe('resolveServableDocBytes', () => { expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith( WORKSPACE_ID, source.toString('utf-8'), - 'pdf' + 'pdf', + { maxBytes: undefined, signal: undefined } ) expect(mockLoadCompiledDoc).not.toHaveBeenCalled() expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() @@ -242,7 +253,8 @@ describe('resolveServableDocBytes', () => { expect(mockLoadPublishedCompiledDoc).toHaveBeenCalledWith( WORKSPACE_ID, source.toString('utf-8'), - 'pdf' + 'pdf', + { maxBytes: undefined, signal: undefined } ) expect(mockLoadCompiledDoc).not.toHaveBeenCalled() }) @@ -307,7 +319,13 @@ describe('resolveServableDocBytes', () => { contentType: 'application/pdf', }) expect(mockReadWorkspaceFileMetadata).not.toHaveBeenCalled() - expect(mockLoadCompiledDoc).toHaveBeenCalledWith(WORKSPACE_ID, source.toString('utf-8'), 'pdf') + expect(mockLoadCompiledDoc).toHaveBeenCalledWith( + WORKSPACE_ID, + source.toString('utf-8'), + 'pdf', + undefined, + { maxBytes: undefined, signal: undefined } + ) expect(mockExecuteInSandbox).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 39739a99d3e..bede6ec4b2e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -200,26 +200,19 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) - it('projects a terminal tense for every settled row, present tense only while running', () => { + it('keeps unsuccessful actions neutral without rewriting them as completed', () => { expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows') expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe( 'Comparing workflows' ) - // An errored row must not read as still running — the frozen present-tense - // title ("Searching for X" forever) was reported as a stuck tool call. - expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe( - 'Failed comparing workflows' - ) + expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows') expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe( - 'Failed searching for admin mentions' + 'Searching for admin mentions' ) expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe( 'Stopped comparing workflows' ) - // Non-gerund titles get a prefix rather than a bad rewrite. - expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe( - 'Failed: Read recent emails' - ) + expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe('Read recent emails') }) }) @@ -688,11 +681,28 @@ describe('terminal-title projection is idempotent', () => { expect(getToolStatusDisplayTitle(storeErrorLabel, 'rejected')).toBe(storeErrorLabel) }) - it('never stacks a second Failed prefix', () => { + it('removes historical failure prefixes idempotently', () => { const once = getToolStatusDisplayTitle('Reading table', 'error') - expect(once).toBe('Failed reading table') + expect(once).toBe('Reading table') expect(getToolStatusDisplayTitle(once, 'error')).toBe(once) - expect(getToolStatusDisplayTitle('Failed: Something', 'error')).toBe('Failed: Something') + expect(getToolStatusDisplayTitle('Failed: Something', 'error')).toBe('Something') + }) + + it.each([ + ['Failed: Failed reading notes', 'Reading notes'], + ['Failed: Locating reference material', 'Locating reference material'], + ['Failed', 'Tool activity'], + ['Reading failed runs', 'Reading failed runs'], + ['FailedJobs report', 'FailedJobs report'], + ['iPhone metadata', 'iPhone metadata'], + ['Failed: eBay metadata', 'eBay metadata'], + ['failed reading notes', 'Reading notes'], + ['Failed failed reading notes', 'Reading notes'], + ])('normalizes only leading outcome wording: %s', (title, expected) => { + expect(getToolStatusDisplayTitle(title, 'error')).toBe(expected) + expect(getToolStatusDisplayTitle(title, 'rejected')).toBe(expected) + expect(getToolStatusDisplayTitle(expected, 'error')).toBe(expected) + expect(getToolStatusDisplayTitle(expected, 'rejected')).toBe(expected) }) it('leaves a store-phrased skip label alone when cancelled', () => { @@ -702,10 +712,8 @@ describe('terminal-title projection is idempotent', () => { expect(getToolStatusDisplayTitle(stopped, 'cancelled')).toBe(stopped) }) - it('still projects an ordinary present-tense title', () => { - expect(getToolStatusDisplayTitle('Searching Sim docs', 'error')).toBe( - 'Failed searching Sim docs' - ) + it('leaves unsuccessful action wording intact and labels cancellation', () => { + expect(getToolStatusDisplayTitle('Searching Sim docs', 'error')).toBe('Searching Sim docs') expect(getToolStatusDisplayTitle('Running workflow', 'cancelled')).toBe( 'Stopped running workflow' ) @@ -854,10 +862,10 @@ describe('model-authored activity outcomes', () => { ['success', 'Revisando facturas', 'Revisando facturas'], ['success', 'Stopped checking invoices', 'Stopped checking invoices'], ['success', 'Completed: Check invoices', 'Completed: Check invoices'], - ['error', 'Failed: Fetching invoices', 'Failed: Fetching invoices'], - ['error', 'Stopped checking invoices', 'Failed checking invoices'], - ['error', 'Completed checking invoices', 'Failed checking invoices'], - ['rejected', 'Failed checking invoices', 'Failed checking invoices'], + ['error', 'Failed: Fetching invoices', 'Fetching invoices'], + ['error', 'Stopped checking invoices', 'Checking invoices'], + ['error', 'Completed checking invoices', 'Checking invoices'], + ['rejected', 'Failed checking invoices', 'Checking invoices'], ['cancelled', 'Stopped reading notes', 'Stopped reading notes'], ['interrupted', 'Completed: Check invoices', 'Stopped: Check invoices'], ['skipped', 'Failed: Checking invoices', 'Skipped: Checking invoices'], diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 6e3dbd905d1..8101ab909e2 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1419,16 +1419,7 @@ export function getToolCompletedTitle(title: string): string | undefined { return past + title.slice(firstWord.length) } -/** - * Titles that already say the work is over. - * - * Two layers project a terminal tense: the client tool store phrases its own - * error and skip labels ("Attempted to read X", "Skipped reading X"), and this - * module projects again at the render boundary. Re-projecting an - * already-projected title stacked prefixes — "Failed: Failed: Attempted to read - * metadata for thread_tracking" — and even a single pass over a store label - * reads as doubly hedged. Whichever layer spoke first wins. - */ +/** Recognize terminal wording already supplied by the tool store or persisted history. */ const TERMINAL_TITLE_PREFIXES = new Set(['Failed', 'Attempted', 'Skipped', 'Stopped']) function firstWordOf(title: string): string { @@ -1444,7 +1435,7 @@ function statesTerminalOutcome(title: string): boolean { /** Apply one terminal outcome prefix while preserving already-resolved titles. */ function getToolOutcomeTitle( title: string, - outcome: 'Failed' | 'Stopped' | 'Skipped', + outcome: 'Stopped' | 'Skipped', preserveExistingOutcome: boolean ): string { if (preserveExistingOutcome && statesTerminalOutcome(title)) return title @@ -1462,10 +1453,26 @@ function getToolOutcomeTitle( return `${outcome}: ${title}` } +/** Error rows describe the action without failure badges or claims of completion. */ +function getNeutralToolActionTitle(title: string): string { + let action = title + while (action) { + const firstWord = firstWordOf(action) + const prefix = firstWord.replace(/:$/, '').toLowerCase() + if (!['failed', 'stopped', 'skipped', 'completed'].includes(prefix)) break + action = action.slice(firstWord.length).trimStart() + } + if (!action) return 'Tool activity' + if (action === title) return title + const firstWord = firstWordOf(action) + const gerund = firstWord.charAt(0).toUpperCase() + firstWord.slice(1) + return COMPLETED_VERB_REWRITES[gerund] ? gerund + action.slice(firstWord.length) : action +} + /** * Resolve a tool title at the rendering boundary. Successful calls use a known * past-tense rewrite when available and otherwise preserve the wording. - * Failed, stopped, and skipped calls retain explicit outcome labels. + * Unsuccessful calls keep a neutral action; stopped and skipped calls retain their labels. */ export function getToolStatusDisplayTitle( title: string, @@ -1482,7 +1489,7 @@ export function getToolStatusDisplayTitle( return getToolCompletedTitle(title) ?? title } if (status === 'error' || status === 'rejected') { - return getToolOutcomeTitle(title, 'Failed', !description) + return getNeutralToolActionTitle(title) } if (status === 'cancelled' || status === 'aborted' || status === 'interrupted') { return getToolOutcomeTitle(title, 'Stopped', !description) diff --git a/apps/sim/lib/core/outbox/processor.test.ts b/apps/sim/lib/core/outbox/processor.test.ts index e63b025eca5..d20af17bc58 100644 --- a/apps/sim/lib/core/outbox/processor.test.ts +++ b/apps/sim/lib/core/outbox/processor.test.ts @@ -33,6 +33,7 @@ vi.mock('@/lib/knowledge/application/slack-search/outbox', () => ({ vi.mock('@/lib/knowledge/documents/processing-outbox-handler', () => ({ knowledgeDocumentProcessingOutboxHandlers: {}, })) +vi.mock('@/lib/mothership/inbox/cleanup-outbox', () => ({ inboxCleanupOutboxHandlers: {} })) vi.mock('@/lib/organizations/resource-cleanup', () => ({ organizationResourceCleanupOutboxHandlers: {}, })) diff --git a/apps/sim/lib/core/outbox/processor.ts b/apps/sim/lib/core/outbox/processor.ts index 7c9fbe9fea7..464460579bc 100644 --- a/apps/sim/lib/core/outbox/processor.ts +++ b/apps/sim/lib/core/outbox/processor.ts @@ -18,6 +18,7 @@ import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-sea import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery' +import { inboxCleanupOutboxHandlers } from '@/lib/mothership/inbox/cleanup-outbox' import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' @@ -42,6 +43,7 @@ const handlers = { ...directGrantOutboxHandlers, ...knowledgeDocumentProcessingOutboxHandlers, ...organizationResourceCleanupOutboxHandlers, + ...inboxCleanupOutboxHandlers, ...permissionAccessRequestOutboxHandlers, ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts index 967e2a6e487..f3b857699e4 100644 --- a/apps/sim/lib/core/outbox/service.ts +++ b/apps/sim/lib/core/outbox/service.ts @@ -83,7 +83,8 @@ export interface DeferredOutboxHandlerResult { * Defaults to true for an external acknowledgement with a finite retry * budget. False is reserved for waits on an internal dependency whose own * outbox row independently reaches completed or dead-letter, and for - * bounded continuation after durable progress (`continueOutboxHandler`). + * bounded continuation after durable progress (`continueOutboxHandler`), + * or external polling with a separately persisted, finite poll allowance. */ consumeAttempt?: boolean } diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index c9a7d3b9e95..cd2664c6bb3 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -83,12 +83,6 @@ const STATIC_SCRIPT_SRC = [ 'https://www.googleadservices.com', 'https://googleads.g.doubleclick.net', 'https://analytics.ahrefs.com', - // HubSpot tracking (landing pages) — loader plus the - // analytics/form-tracking/banner scripts it injects as