diff --git a/apps/docs/components/workflow-preview/block-display-workflows.ts b/apps/docs/components/workflow-preview/block-display-workflows.ts index aa4e2cf326c..eb456340eac 100644 --- a/apps/docs/components/workflow-preview/block-display-workflows.ts +++ b/apps/docs/components/workflow-preview/block-display-workflows.ts @@ -326,11 +326,11 @@ export const BLOCK_DISPLAY_WORKFLOWS: Record = { }, webhook_trigger: { id: 'webhook_trigger', - name: 'Webhook', + name: 'Webhook Trigger', blocks: [ { id: 'webhook_trigger', - name: 'Webhook', + name: 'Webhook Trigger', type: 'webhook', bgColor: '#10B981', position: { x: 0, y: 0 }, diff --git a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx index 374fd03b913..845347e340c 100644 --- a/apps/docs/content/docs/en/workflows/blocks/webhook.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/webhook.mdx @@ -1,6 +1,6 @@ --- -title: Outgoing Webhook -description: The Outgoing Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. +title: Webhook +description: The Webhook block sends an HTTP POST to an external endpoint, with automatic headers and optional signing. pageType: reference --- @@ -8,7 +8,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { BlockPreview, WorkflowPreview, WEBHOOK_NOTIFY_WORKFLOW, WEBHOOK_TRIGGER_WORKFLOW } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The Outgoing Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. +The Webhook block sends HTTP POST requests to external webhook endpoints with automatic webhook headers and optional HMAC signing. @@ -77,16 +77,16 @@ Format the result, then POST it to a Slack, Discord, or custom endpoint. -When the Condition passes, the Outgoing Webhook starts a process in another system. +When the Condition passes, the Webhook starts a process in another system. -The Outgoing Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). +The Webhook block always uses POST. For other HTTP methods or more control, use the [API block](/workflows/blocks/api). diff --git a/apps/docs/package.json b/apps/docs/package.json index 41a7490421c..9fde4625a2a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -30,7 +30,7 @@ "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", "lucide-react": "^0.511.0", - "next": "16.2.6", + "next": "16.2.11", "next-themes": "^0.4.6", "postgres": "^3.4.5", "react": "19.2.4", diff --git a/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx index c2e9b566956..d06e6ca7a88 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-header/hero-header.tsx @@ -6,6 +6,12 @@ import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout' interface LandingHeroHeaderProps { description: string + /** + * Optional second paragraph beneath the description - a self-contained + * definition of the page's subject, kept quotable for answer engines (GEO). + * Omitted by the homepage, so its hero renders unchanged. + */ + definition?: string eyebrow?: ReactNode heading: ReactNode headingId: string @@ -17,6 +23,7 @@ interface LandingHeroHeaderProps { */ export function LandingHeroHeader({ description, + definition, eyebrow, heading, headingId, @@ -37,6 +44,12 @@ export function LandingHeroHeader({ {description}

+ {definition ? ( +

+ {definition} +

+ ) : null} +
diff --git a/apps/sim/app/(landing)/components/hero/hero.tsx b/apps/sim/app/(landing)/components/hero/hero.tsx index e591cf4c6b4..f28d618087c 100644 --- a/apps/sim/app/(landing)/components/hero/hero.tsx +++ b/apps/sim/app/(landing)/components/hero/hero.tsx @@ -86,11 +86,12 @@ export function Hero() { headingId='hero-heading' heading={ <> - Sim is the AI workspace
- for building AI agents. + Sim is the AI workspace for
+ building and managing AI agents. } - description='Open source, with 1,000+ integrations and every major LLM. Build, deploy, and manage agents visually, conversationally, or with code.' + description='Sim is an AI agent and workflow builder for teams creating agents that automate real work. Design workflows visually, describe what you need in natural language, or use code for complete control.' + definition='Connect your agents to 1,000+ integrations and every major LLM, then deploy, monitor, and improve them from one collaborative, open-source workspace.' />
`, OG/Twitter titles (`page.tsx`), and the JSON-LD `WebPage.name` + * below, so the title surfaces never drift. + */ +export const HOME_PAGE_TITLE = 'The AI Workspace | Build, Deploy & Manage AI Agents | Sim' + const HOME_JSON_LD = { '@context': 'https://schema.org', '@graph': [ @@ -31,7 +38,7 @@ const HOME_JSON_LD = { '@type': 'WebPage', '@id': `${SITE_URL}#webpage`, url: SITE_URL, - name: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + name: HOME_PAGE_TITLE, isPartOf: { '@id': `${SITE_URL}#website` }, about: { '@id': `${SITE_URL}#software` }, datePublished: '2024-01-01T00:00:00+00:00', diff --git a/apps/sim/app/(landing)/components/home-structured-data/index.ts b/apps/sim/app/(landing)/components/home-structured-data/index.ts index 61d458f204c..d4a9af3c897 100644 --- a/apps/sim/app/(landing)/components/home-structured-data/index.ts +++ b/apps/sim/app/(landing)/components/home-structured-data/index.ts @@ -1 +1 @@ -export { HOME_PAGE_DESCRIPTION, HomeStructuredData } from './home-structured-data' +export { HOME_PAGE_DESCRIPTION, HOME_PAGE_TITLE, HomeStructuredData } from './home-structured-data' diff --git a/apps/sim/app/(landing)/components/mothership/mothership.tsx b/apps/sim/app/(landing)/components/mothership/mothership.tsx index 62f0d9d34f5..24995307640 100644 --- a/apps/sim/app/(landing)/components/mothership/mothership.tsx +++ b/apps/sim/app/(landing)/components/mothership/mothership.tsx @@ -83,7 +83,7 @@ export function Mothership() { className='max-w-[1200px] text-balance text-[28px] leading-[1.2] max-sm:text-[22px]' > - Everything your agents need, in one workspace. + Everything your AI agents need, in one workspace. Build, run, and watch every agent. diff --git a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx index 10d1f030024..948a27234ba 100644 --- a/apps/sim/app/(landing)/components/product-demo/product-demo.tsx +++ b/apps/sim/app/(landing)/components/product-demo/product-demo.tsx @@ -45,8 +45,9 @@ export function ProductDemo() { Describe it. Sim builds it.

- Tell Sim what you need in plain English and it wires blocks, models, and integrations - into a working agent. + Describe what you want to automate in plain English. Sim acts as an AI agent builder, + connecting the right blocks, models, tools, and data into a working workflow that you + can review, customize, test, and deploy.

diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx index c430eed9f7f..3aa995e65f3 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx @@ -3,8 +3,9 @@ import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/con import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types' /** - * The header block of a card row - an `

` title, a body-color subtitle, and - * a single pill CTA, stacked with named spacing constants. Extracted from + * The header block of a card row - an `

` title, a body-color subtitle, an + * optional second subtitle paragraph, and a single pill CTA, stacked with named + * spacing constants. Extracted from * {@link SolutionsCardRow} so layouts that place row headers inside a shared * grid (the enterprise feature grid) render the exact same header chrome. */ @@ -27,6 +28,11 @@ export function SolutionsCardRowHeader({ row, headingId }: SolutionsCardRowHeade

{row.subtitle}

+ {row.note ? ( +

+ {row.note} +

+ ) : null}
diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx index 1c40d68d4e8..80fd08911c0 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx @@ -67,6 +67,7 @@ export function SolutionsHero({ hero, align = 'left', variant = 'solutions' }: S heading={hero.heading} headingId='solutions-hero-heading' description={hero.description} + definition={hero.definition} />
, }, { @@ -100,7 +103,8 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { }, { title: 'Manage the full lifecycle', - description: 'Version, monitor, and edit every agent in Sim as your workflows evolve.', + description: + 'Version, monitor, and update every enterprise AI agent in Sim as your business requirements, integrations, and governance policies evolve.', visual: , }, ], @@ -109,7 +113,7 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { id: 'governance', title: 'Governance and Security for Enterprise AI Agents', subtitle: - 'Sim gives security teams role-based access, approval paths, and a complete audit trail for every deployment.', + 'Control how enterprise AI agents access data, use tools, and take action. Sim provides SSO, permission groups, approval paths, configurable data retention, and append-only audit logs for every security-relevant change.', cta: { label: 'See security', href: DEMO_HREF }, cards: [ { @@ -137,7 +141,8 @@ const ENTERPRISE_CONFIG: SolutionsPageConfig = { id: 'deploy', title: 'Deploy Enterprise Workflow Agents with Confidence', subtitle: - 'Test in staging, watch live runs, and roll back in seconds. Sim versions every deployment.', + 'Enterprise workflow agents need controlled release processes. Test changes in staging, observe production runs, version every deployment, and restore a stable version if updates cause an issue.', + note: 'Deploy Sim on your own infrastructure with Docker or Kubernetes when your organization requires greater control over its environment and data.', cta: { label: 'Explore deployment', href: SIGNUP_HREF }, cards: [ { diff --git a/apps/sim/app/(landing)/files/files.tsx b/apps/sim/app/(landing)/files/files.tsx index ccf2123ed40..10033e2a708 100644 --- a/apps/sim/app/(landing)/files/files.tsx +++ b/apps/sim/app/(landing)/files/files.tsx @@ -119,7 +119,7 @@ const FILES_CONFIG: SolutionsPageConfig = { }, { id: 'agents', - title: 'Agents read, parse, and produce files.', + title: 'AI agents read, parse, and produce files.', subtitle: 'Sim agents take files as inputs, pull the data out, and write new files back to the store for your team.', cta: { label: 'Build a file-handling agent', href: '/signup' }, diff --git a/apps/sim/app/(landing)/knowledge/knowledge.tsx b/apps/sim/app/(landing)/knowledge/knowledge.tsx index f70d1f06e41..4ccdb912189 100644 --- a/apps/sim/app/(landing)/knowledge/knowledge.tsx +++ b/apps/sim/app/(landing)/knowledge/knowledge.tsx @@ -55,7 +55,7 @@ const KNOWLEDGE_CONFIG: SolutionsPageConfig = { rows: [ { id: 'memory', - title: 'Give agents memory of your data.', + title: 'Give AI agents memory of your data.', subtitle: 'Sim turns your docs and sources into searchable memory every agent can read, so answers come from your data instead of guesswork.', cta: { label: 'Explore Knowledge Base', href: '/signup' }, diff --git a/apps/sim/app/(landing)/logs/logs.tsx b/apps/sim/app/(landing)/logs/logs.tsx index b3755a2973f..8a00c03f2e7 100644 --- a/apps/sim/app/(landing)/logs/logs.tsx +++ b/apps/sim/app/(landing)/logs/logs.tsx @@ -57,7 +57,7 @@ const LOGS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'trace', - title: 'See exactly what every agent did.', + title: 'See exactly what every AI agent did.', subtitle: 'Sim records every step of every agent run, so teams can follow each decision, tool call, and output on one timeline.', cta: { label: 'Explore Logs in Sim', href: '/signup' }, diff --git a/apps/sim/app/(landing)/page.tsx b/apps/sim/app/(landing)/page.tsx index 27bd43bbad6..b6ac4c57574 100644 --- a/apps/sim/app/(landing)/page.tsx +++ b/apps/sim/app/(landing)/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next' import { SITE_URL } from '@/lib/core/utils/urls' -import { HOME_PAGE_DESCRIPTION } from '@/app/(landing)/components/home-structured-data' +import { + HOME_PAGE_DESCRIPTION, + HOME_PAGE_TITLE, +} from '@/app/(landing)/components/home-structured-data' import Landing from '@/app/(landing)/landing' export const revalidate = 3600 @@ -8,7 +11,7 @@ export const revalidate = 3600 export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { - absolute: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + absolute: HOME_PAGE_TITLE, }, description: HOME_PAGE_DESCRIPTION, keywords: @@ -22,7 +25,7 @@ export const metadata: Metadata = { telephone: false, }, openGraph: { - title: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + title: HOME_PAGE_TITLE, description: HOME_PAGE_DESCRIPTION, type: 'website', url: SITE_URL, @@ -42,7 +45,7 @@ export const metadata: Metadata = { card: 'summary_large_image', site: '@simdotai', creator: '@simdotai', - title: 'Sim, The AI Workspace | Build, Deploy & Manage AI Agents', + title: HOME_PAGE_TITLE, description: HOME_PAGE_DESCRIPTION, images: { url: '/logo/426-240/reverse/small.png', diff --git a/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx index 91a019b78b3..464d2313b8d 100644 --- a/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx +++ b/apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx @@ -56,7 +56,7 @@ const SCHEDULED_TASKS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'schedule', - title: 'Put any agent on a schedule.', + title: 'Put any AI agent on a schedule.', subtitle: 'Sim turns a workflow into a scheduled task with one trigger, from 15-minute intervals to monthly closes, in your timezone.', cta: { label: 'Schedule your first agent', href: '/signup' }, diff --git a/apps/sim/app/(landing)/tables/tables.tsx b/apps/sim/app/(landing)/tables/tables.tsx index 8921d827660..5f16e7420a2 100644 --- a/apps/sim/app/(landing)/tables/tables.tsx +++ b/apps/sim/app/(landing)/tables/tables.tsx @@ -56,7 +56,7 @@ const TABLES_CONFIG: SolutionsPageConfig = { rows: [ { id: 'records', - title: 'Give agents structured data to act on.', + title: 'Give AI agents structured data to act on.', subtitle: 'Sim stores the leads, tickets, and invoices agents work with as tables in the same workspace as the agents.', cta: { label: 'Explore Tables', href: '/signup' }, @@ -85,7 +85,7 @@ const TABLES_CONFIG: SolutionsPageConfig = { }, { id: 'memory', - title: 'Tables are your agents’ memory.', + title: 'Tables are your AI agents’ memory.', subtitle: 'Sim carries state between runs. Every run writes rows, every change is recorded, and agents pick up exactly where they left off.', cta: { label: 'See how agents use Tables', href: '/signup' }, diff --git a/apps/sim/app/(landing)/workflows/workflows.tsx b/apps/sim/app/(landing)/workflows/workflows.tsx index 8edf9c34431..862082c26c1 100644 --- a/apps/sim/app/(landing)/workflows/workflows.tsx +++ b/apps/sim/app/(landing)/workflows/workflows.tsx @@ -58,7 +58,7 @@ const WORKFLOWS_CONFIG: SolutionsPageConfig = { rows: [ { id: 'build', - title: 'Build agents the way that fits.', + title: 'Build AI agents the way that fits.', subtitle: 'Sim lets teams build agents visually, in natural language, or with code, wiring up any model and 1,000+ integrations in one workspace.', cta: { label: 'Explore the workflow builder', href: '/signup' }, diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index c4a26459ecb..4d26c178f5b 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -13,6 +13,7 @@ import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' const logger = createLogger('ShopifyStore') @@ -46,12 +47,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch(`https://${shopDomain}/admin/api/2024-10/shop.json`, { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - }) + const shopResponse = await fetch( + `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': accessToken, + 'Content-Type': 'application/json', + }, + } + ) if (!shopResponse.ok) { const errorText = await shopResponse.text() diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index ea6bc0bc89a..33717fdcb39 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -328,23 +328,25 @@ export const DELETE = withRouteHandler(async (request: NextRequest, { params }: const { deletedDocs, docCount } = await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) + // Includes pending-removal (tombstoned) docs — the connector is being + // deleted, so there's no future sync left to confirm or resurrect them. const docs = await tx .select({ id: document.id, fileUrl: document.fileUrl }) .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) + const documentIds = docs.map((doc) => doc.id) if (deleteDocuments) { - const documentIds = docs.map((doc) => doc.id) if (documentIds.length > 0) { await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) await tx.delete(document).where(inArray(document.id, documentIds)) } + } else if (documentIds.length > 0) { + // Kept documents become normal standalone KB entries once their connector + // is gone — resurrect any pending-removal ones rather than leaving them + // invisible tombstones with no future sync left to ever confirm or + // resurrect them. + await tx.update(document).set({ deletedAt: null }).where(inArray(document.id, documentIds)) } const deletedConnectors = await tx diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 63ed87220fb..1d6482390fd 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -36,6 +36,7 @@ import { validateMapping, wouldExceedRowLimit, } from '@/lib/table' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' import { getUserSettings } from '@/lib/users/queries' import { @@ -176,11 +177,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro timezone = timezoneValidation.data } - const delimiter = extensionValidation.data === 'tsv' ? '\t' : ',' - const parser = createCsvParser(delimiter) + // The extension only picks the fallback — the separator is sniffed from the file's + // head so semicolon/pipe exports (European-locale Excel) don't land in one column. + const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( + file.stream, + extensionValidation.data === 'tsv' ? '\t' : ',' + ) + let headers: string[] = [] + const parser = createCsvParser(delimiter, (parsedHeaders) => { + headers = parsedHeaders + }) // `.pipe` doesn't forward source errors; forward them so the iterator throws. - file.stream.on('error', (streamErr) => parser.destroy(streamErr)) - file.stream.pipe(parser) + csvStream.on('error', (streamErr) => parser.destroy(streamErr)) + csvStream.pipe(parser) const rows: Record[] = [] for await (const record of parser as AsyncIterable>) { rows.push(record) @@ -188,7 +197,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (rows.length === 0) { return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 }) } - const headers = Object.keys(rows[0]) let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema) let prospectiveTable: TableDefinition = table diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index fb29cffab8f..2c0a3cf6614 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -26,6 +26,7 @@ import { type TableDefinition, type TableSchema, } from '@/lib/table' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { @@ -107,12 +108,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const delimiter = extensionResult.data === 'tsv' ? '\t' : ',' + // The extension only picks the fallback — the separator is sniffed from the file's + // head so semicolon/pipe exports (European-locale Excel) don't land in one column. + const { delimiter, stream: csvStream } = await sniffCsvDelimiterFromStream( + file.stream, + extensionResult.data === 'tsv' ? '\t' : ',' + ) - const parser = createCsvParser(delimiter) + let csvHeaders: string[] = [] + const parser = createCsvParser(delimiter, (headers) => { + csvHeaders = headers + }) // `.pipe` doesn't forward source errors; forward them so the iterator throws. - file.stream.on('error', (err) => parser.destroy(err)) - file.stream.pipe(parser) + csvStream.on('error', (err) => parser.destroy(err)) + csvStream.pipe(parser) interface ImportState { table: TableDefinition @@ -139,7 +148,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { /** Infer the schema from the buffered sample and create the (empty) table. */ const buildTable = async (sampleRows: Record[]): Promise => { - const inferred = inferSchemaFromCsv(Object.keys(sampleRows[0]), sampleRows) + const inferred = inferSchemaFromCsv(csvHeaders, sampleRows) const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) } const planLimits = await getWorkspaceTableLimits(workspaceId) const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts index c468042a18d..4b33f7de8aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/suggestion-menu-chrome.ts @@ -14,9 +14,10 @@ export const SUGGESTION_SURFACE_CLASS = */ export const SUGGESTION_SCROLL_CLASS = 'max-h-[240px] scroll-py-1.5 overflow-y-auto overscroll-none' -/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. */ +/** A selectable row: icon + label, 14px icon in `--text-icon`, truncating label. The `img` rules + * size custom-block image icons (rendered as ``, so the `svg` rules never reach them). */ export const SUGGESTION_ITEM_CLASS = - 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]' + 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)] [&_img]:pointer-events-none [&_img]:size-[14px] [&_img]:shrink-0' /** A group heading above a run of rows. */ export const SUGGESTION_GROUP_LABEL_CLASS = diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 2b94fcff9b4..f5d50141d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -107,13 +107,7 @@ export function ToolCallItem({ return (
{BlockIcon && ( - // Size via inline style: a custom block's image icon carries a trailing - // `size-full` that defeats size *classes* (it fills tiled surfaces), so a - // class-only size renders the uploaded icon at natural size here. - + )} {isExecuting ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx index ae2bdc00368..428644b62f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal.tsx @@ -12,9 +12,10 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { isApiClientError } from '@/lib/api/client/errors' -import type { - TokenServiceAccountDescriptor, - TokenServiceAccountField, +import { + getTokenServiceAccountErrorMessage, + type TokenServiceAccountDescriptor, + type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' import { useCreateWorkspaceCredential, @@ -23,33 +24,6 @@ import { const logger = createLogger('TokenServiceAccountModal') -const FALLBACK_ERROR_MESSAGE = "We couldn't add this credential. Try again in a moment." - -/** - * Maps server `error.code` values from token service-account verification to - * user-facing messages, personalized with the provider's own token noun. - */ -function messageForTokenAccountError( - err: unknown, - descriptor: TokenServiceAccountDescriptor -): string { - if (isApiClientError(err) && err.code) { - switch (err.code) { - case 'invalid_credentials': - return `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` - case 'site_not_found': - return "We couldn't find an account at that domain. Check the spelling and try again." - case 'provider_unavailable': - return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.` - case 'duplicate_display_name': - return 'A credential with that name already exists in this workspace.' - default: - return FALLBACK_ERROR_MESSAGE - } - } - return FALLBACK_ERROR_MESSAGE -} - function normalizeDomainInput(raw: string): string { return raw .trim() @@ -154,7 +128,8 @@ export function TokenServiceAccountModal({ } onOpenChange(false) } catch (err: unknown) { - setError(messageForTokenAccountError(err, descriptor)) + const code = isApiClientError(err) ? err.code : undefined + setError(getTokenServiceAccountErrorMessage(descriptor, code)) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) } } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx index 9ea1131717c..6315cc4e304 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx @@ -332,7 +332,7 @@ const TraceTreeRow = memo(function TraceTreeRow({ )} {!isIterationType(span.type) && (
{BlockIcon && } @@ -707,7 +707,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
{!isIterationType(span.type) && (
{BlockIcon && } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx index 8392000e51a..b6702c721f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -178,7 +178,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 2eee7a1c4ee..6ea241ab22f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -25,7 +25,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants' -import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import' +import { + buildAutoMapping, + CSV_DELIMITER_SNIFF_BYTES, + type CsvDelimiter, + detectCsvDelimiter, + parseCsvBuffer, +} from '@/lib/table/import' import type { TableDefinition } from '@/lib/table/types' import { type CsvImportMode, @@ -107,8 +113,13 @@ interface ParsedCsv { sampleRows: Record[] } -/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */ -async function parseCsvPreview(file: File, delimiter: ',' | '\t') { +/** + * Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. + * + * The separator is sniffed from the same leading bytes the server sniffs, so the mapping shown + * here always matches the columns the import will actually produce. + */ +async function parseCsvPreview(file: File, fallbackDelimiter: CsvDelimiter) { const sliced = file.size > CSV_PREVIEW_BYTES const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file let bytes = new Uint8Array(await blob.arrayBuffer()) @@ -116,6 +127,13 @@ async function parseCsvPreview(file: File, delimiter: ',' | '\t') { const lastNewline = bytes.lastIndexOf(0x0a) if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1) } + // The sniff sample is the whole file only when nothing was sliced off and it fits the window; + // otherwise it's a truncated prefix whose last line may be partial. + const delimiter = await detectCsvDelimiter( + bytes.subarray(0, CSV_DELIMITER_SNIFF_BYTES), + fallbackDelimiter, + { complete: !sliced && bytes.length <= CSV_DELIMITER_SNIFF_BYTES } + ) return parseCsvBuffer(bytes, delimiter) } @@ -180,8 +198,7 @@ export function ImportCsvDialog({ setParsing(true) setParseError(null) try { - const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ',' - const { headers, rows } = await parseCsvPreview(file, delimiter) + const { headers, rows } = await parseCsvPreview(file, ext === 'tsv' ? '\t' : ',') const autoMapping = buildAutoMapping(headers, table.schema) setParsed({ file, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index a4a0b8fd1f9..c6c9ee01ec6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -28,7 +28,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx index 7899d1cf8fd..d28620e2a38 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/connection-blocks.tsx @@ -145,7 +145,7 @@ function ConnectionItem({ }} >
{Icon && ( @@ -154,7 +154,7 @@ function ConnectionItem({ 'transition-transform duration-200', getTileIconColorClass(bgColor), hasFields && 'group-hover:scale-110', - '!h-[9px] !w-[9px]' + '!size-[9px]' )} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 6f62851c289..cd8b9df42cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -387,7 +387,7 @@ const TagIcon: React.FC<{ color: string }> = ({ icon, color }) => (
{typeof icon === 'string' ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 3cfcb3fe3e8..0b34f353382 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -408,7 +408,7 @@ function createToolIcon( ) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index bd9558e6789..e88f9aa79d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -373,7 +373,7 @@ export function Editor() {
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
{Icon && ( @@ -173,14 +173,14 @@ function syncCachesToOverlayVersion(version: number) { /** * Gets triggers data, computing it once per overlay version and caching for - * subsequent calls. Non-integration triggers (Start, Schedule, Webhook) are + * subsequent calls. Non-integration triggers (Start, Schedule, Webhook Trigger) are * prioritized first, followed by all other triggers sorted alphabetically. */ function getTriggers(overlayVersion: number): BlockItem[] { syncCachesToOverlayVersion(overlayVersion) if (cachedTriggers === null) { const allTriggers = getTriggersForSidebar() - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = allTriggers.sort((a, b) => { const aIndex = priorityOrder.indexOf(a.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 7d36f6a97bf..6d945a8bcc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -124,7 +124,7 @@ const BlockRow = memo(function BlockRow({ >
{BlockIcon && ( @@ -300,7 +300,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({ >
{BlockIcon && ( @@ -427,7 +427,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({ >
{BlockIcon && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 1c095a9c4c1..d287c7a94a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -1198,7 +1198,7 @@ function PreviewEditorContent({
{block.type !== 'note' && (
{!isNoteBlock && (
(null) + const searchInputRef = useRef(null) + const workspaceListRef = useRef(null) + + const [workspaceSearch, setWorkspaceSearch] = useState('') + const [highlightedId, setHighlightedId] = useState(null) + + const showSearch = workspaces.length > WORKSPACE_SEARCH_THRESHOLD + const searchQuery = workspaceSearch.trim().toLowerCase() + const filteredWorkspaces = + showSearch && searchQuery + ? workspaces.filter((w) => w.name.toLowerCase().includes(searchQuery)) + : workspaces + + /** + * The highlighted row resolved from the highlighted workspace's identity, not + * a stored position. Tracking the id (rather than a numeric index) keeps the + * highlight on the same workspace when the list shrinks, grows, or reorders + * while the menu is open (a live membership change or background refetch); + * a missing id (filtered out) or no selection falls back to the first row. + * `activeIndex` is the single source of truth for Enter, the visual highlight, + * and the scroll target, so those three can never diverge. + */ + const activeIndex = highlightedId + ? Math.max( + 0, + filteredWorkspaces.findIndex((w) => w.id === highlightedId) + ) + : 0 + + useEffect(() => { + if (!showSearch || !isWorkspaceMenuOpen) return + const el = workspaceListRef.current?.querySelector( + `[data-workspace-row-idx="${activeIndex}"]` + ) + el?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, showSearch, isWorkspaceMenuOpen]) + + /** + * Seed the highlight to the first result whenever the current one is absent — + * on open, or after typing filters the highlighted workspace out. This keeps + * `highlightedId` pinned to a real workspace identity rather than falling back + * to a bare positional default, so a reorder or query change carries the + * highlight along with its workspace instead of stranding it on whatever now + * occupies the first row. + */ + useEffect(() => { + if (!showSearch || !isWorkspaceMenuOpen || filteredWorkspaces.length === 0) return + const present = highlightedId !== null && filteredWorkspaces.some((w) => w.id === highlightedId) + if (!present) setHighlightedId(filteredWorkspaces[0].id) + }, [highlightedId, filteredWorkspaces, showSearch, isWorkspaceMenuOpen]) + + /** + * Clear the query and highlight whenever the menu closes, by any path — + * selecting a workspace closes it via `setIsWorkspaceMenuOpen(false)` without + * routing through `onOpenChange`, so resetting here (not in the open handler) + * keeps a stale search from persisting into the next open. Not gated on + * `showSearch`: if the list drops to the threshold while a query is active the + * search input unmounts, and this still clears the now-invisible filter. For + * users who never search, both setters no-op (same value) so there is no cost. + */ + useEffect(() => { + if (isWorkspaceMenuOpen) return + setWorkspaceSearch('') + setHighlightedId(null) + }, [isWorkspaceMenuOpen]) const [isMounted, setIsMounted] = useState(false) useEffect(() => { @@ -361,6 +430,9 @@ function WorkspaceHeaderImpl({ return } setIsWorkspaceMenuOpen(open) + if (open && showSearch) { + requestAnimationFrame(() => searchInputRef.current?.focus()) + } }} > @@ -422,14 +494,63 @@ function WorkspaceHeaderImpl({
) : ( <> -
- {workspaces.map((workspace) => { + {showSearch && ( + setWorkspaceSearch(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.nativeEvent.isComposing) return + if (filteredWorkspaces.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = (activeIndex + 1) % filteredWorkspaces.length + setHighlightedId(filteredWorkspaces[next].id) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const next = + (activeIndex - 1 + filteredWorkspaces.length) % filteredWorkspaces.length + setHighlightedId(filteredWorkspaces[next].id) + } else if (e.key === 'Enter') { + e.preventDefault() + const target = filteredWorkspaces[activeIndex] + if (target) onWorkspaceSwitch(target) + } + }} + className='mb-1.5' + /> + )} +
+ {filteredWorkspaces.length === 0 && workspaceSearch && ( +
+ No results for "{workspaceSearch}" +
+ )} + {filteredWorkspaces.map((workspace, idx) => { const initial = getWorkspaceInitial(workspace.name) const isActive = workspace.id === workspaceId const isMenuOpen = menuOpenWorkspaceId === workspace.id - + const isKeyboardHighlighted = showSearch && idx === activeIndex + + /** + * Hover-highlight is wired to `onMouseMove`, not `onMouseEnter`: a + * keyboard-driven `scrollIntoView` slides rows under a stationary cursor + * and fires `mouseenter`, which would hijack the keyboard selection. + * `mousemove` only fires on real pointer motion, so hover follows the + * mouse without fighting the arrow keys. + */ return ( -
+
setHighlightedId(workspace.id) : undefined} + > {editingWorkspaceId === workspace.id ? (
) => createElement(Webhook, p export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', - name: 'Webhook', + name: 'Webhook Trigger', description: 'Receive webhooks from any service by configuring a custom webhook.', category: 'triggers', icon: WebhookIcon, @@ -17,7 +17,7 @@ export const GenericWebhookBlock: BlockConfig = { triggerAllowed: true, bestPractices: ` - You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}' - - Continuing example above, the body can be accessed in downstream block using dot notation. E.g. and + - Continuing example above, the body can be accessed in downstream block using dot notation. E.g. and - To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped. - Only use when there's no existing integration for the service with triggerAllowed flag set to true. `, diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 1792db96c52..0cdb2266280 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -7,6 +7,25 @@ import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' import { getTrigger } from '@/triggers' +/** + * Scopes added for the native Sim Slack app trigger (`slack_oauth`): the shared + * app's `app_mention`, assistant-thread, and DM events don't deliver without + * them. Advertised by `slack_v2` and the trigger only — the legacy block has no + * feature that needs them, and listing them there would flag every existing + * Slack credential as missing scopes and prompt a reconnect. + * + * This only controls what each picker *advertises* and treats as missing. The + * authorization request itself is provider-wide + * (`getCanonicalScopesForProvider('slack')`), so any reconnect grants the full + * set regardless of which block started it. + */ +const SLACK_V2_ONLY_SCOPES = new Set(['app_mentions:read', 'assistant:write', 'im:history']) + +/** Slack scopes the legacy v1 block advertises — the set from before the trigger expansion. */ +const SLACK_V1_ADVERTISED_SCOPES = getScopesForService('slack').filter( + (scope) => !SLACK_V2_ONLY_SCOPES.has(scope) +) + export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', @@ -107,7 +126,7 @@ export const SlackBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'slack', - requiredScopes: getScopesForService('slack'), + requiredScopes: SLACK_V1_ADVERTISED_SCOPES, placeholder: 'Select Slack workspace', dependsOn: ['authMethod'], condition: { @@ -2642,6 +2661,9 @@ function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig { ...rest, credentialKind: 'any', placeholder: 'Select Slack account or bot', + // Full set, unlike v1: v2 hosts the native Sim app trigger, whose events + // need the mention/assistant/DM scopes. + requiredScopes: getScopesForService('slack'), credentialLabels: { oauthGroup: 'Sim app', oauthConnect: 'Connect the Sim app', diff --git a/apps/sim/blocks/blocks/webhook_request.ts b/apps/sim/blocks/blocks/webhook_request.ts index b34e78413d4..6fad3395995 100644 --- a/apps/sim/blocks/blocks/webhook_request.ts +++ b/apps/sim/blocks/blocks/webhook_request.ts @@ -4,7 +4,7 @@ import type { RequestResponse } from '@/tools/http/types' export const WebhookRequestBlock: BlockConfig = { type: 'webhook_request', - name: 'Outgoing Webhook', + name: 'Webhook', description: 'Send a webhook request', longDescription: 'Send an HTTP POST request to a webhook URL with automatic webhook headers. Optionally sign the payload with HMAC-SHA256 for secure webhook delivery.', diff --git a/apps/sim/blocks/custom/custom-block-icon.tsx b/apps/sim/blocks/custom/custom-block-icon.tsx index f726f9d3220..4151fa13284 100644 --- a/apps/sim/blocks/custom/custom-block-icon.tsx +++ b/apps/sim/blocks/custom/custom-block-icon.tsx @@ -5,30 +5,32 @@ import { cn } from '@sim/emcn' import { Box } from 'lucide-react' import type { BlockIcon } from '@/blocks/types' +const cache = new Map() + /** * Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `` so * any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every * other block icon. Cached by URL so the component reference stays stable across * the many tiles/nodes that render a custom block. */ -const cache = new Map() - export function makeImageIcon(url: string): BlockIcon { const cached = cache.get(url) if (cached) return cached - // Fill the tile so an uploaded image/logo reads at the same footprint as other - // blocks' colored tiles, instead of a small glyph floating in a transparent - // square. Trailing `size-full` beats a consumer size *class* (twMerge keeps the - // last of a conflict group) so a tiled surface (canvas/toolbar/palette) fills; - // it loses to a consumer inline `style` (specificity) so a tile-less inline - // surface that sizes via `style={{ width, height }}` still renders at its px. + /** + * `size-full` is only the DEFAULT (fills a fixed-size tile parent when no size + * is given); a consumer size class or inline style always wins, so flow + * surfaces that render icons at `size-[14px]` get exactly that. Tiled surfaces + * (canvas node, toolbar, search modal, …) pass a glyph-size class but want the + * image to fill the tile — they opt in with `[&_img]:size-full` on the fixed + * wrapper, which out-specifies the size class on the img. + */ const ImageComponent = memo(({ className, style }: SVGProps) => ( )) // double-cast-allowed: an renderer must satisfy the SVG-typed BlockIcon slot diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index 97df881cb56..70ec8325f84 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -99,7 +99,7 @@ export function SettingsSidebar
({
diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts index 706023ccb93..25b3dfff5cb 100644 --- a/apps/sim/connectors/confluence/confluence.test.ts +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { escapeCql, isCurrentContent } from '@/connectors/confluence/confluence' +import { + escapeCql, + isCurrentContent, + preserveConfluenceCallouts, +} from '@/connectors/confluence/confluence' +import { htmlToPlainText } from '@/connectors/utils' describe('escapeCql', () => { it.concurrent('returns plain strings unchanged', () => { @@ -48,3 +53,248 @@ describe('isCurrentContent', () => { expect(isCurrentContent({ id: '1', status: 'deleted' })).toBe(false) }) }) + +describe('preserveConfluenceCallouts', () => { + it.concurrent('handles empty content', () => { + expect(preserveConfluenceCallouts('')).toBe('') + }) + + it.concurrent('leaves content with no macros unchanged', () => { + const html = '

Just a normal paragraph.

' + expect(preserveConfluenceCallouts(html)).toContain('Just a normal paragraph.') + }) + + it.concurrent('labels a built-in warning macro and keeps its body', () => { + const html = + '
' + + '' + + '

Do NOT use this form for GitLab access.

' + + '
' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[WARNING]') + expect(result).toContain('Do NOT use this form for GitLab access.') + }) + + it.concurrent('labels a built-in info macro', () => { + const html = + '
' + + '

Heads up.

' + + '
' + expect(preserveConfluenceCallouts(html)).toContain('[INFO] Heads up.') + }) + + it.concurrent('labels a built-in note macro', () => { + const html = + '
' + + '

See also.

' + + '
' + expect(preserveConfluenceCallouts(html)).toContain('[NOTE] See also.') + }) + + it.concurrent('labels a built-in tip macro', () => { + const html = + '
' + + '

Pro tip.

' + + '
' + expect(preserveConfluenceCallouts(html)).toContain('[TIP] Pro tip.') + }) + + it.concurrent('labels a generic custom-colored Panel macro using its header title', () => { + const html = + '
' + + '
Do NOT use this form for:
' + + '

GitLab access requests go to the private channel instead.

' + + '
' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Do NOT use this form for:]') + expect(result).toContain('GitLab access requests go to the private channel instead.') + }) + + it.concurrent('preserves word boundaries between a block header and its own content', () => { + const html = + '
Warning:
' + + '

See replacement form.

' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Warning:] See replacement form.') + }) + + it.concurrent( + 'keeps a rich header with a real source space intact, without adding a second one', + () => { + const html = + '
Warning: Do not use
' + + '

See replacement form.

' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Warning: Do not use]') + } + ) + + it.concurrent('falls back to a bare CALLOUT label when a Panel macro has no header text', () => { + const html = + '

Untitled panel body.

' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT]') + expect(result).toContain('Untitled panel body.') + }) + + it.concurrent( + 'keeps the exclusion marker attached to its content through htmlToPlainText, even across surrounding whitespace collapse', + () => { + const html = + '

Intro paragraph.

\n\n' + + '
' + + '

Do NOT use this form for:

' + + '
  • GitLab
' + + '
\n\n' + + '

Trailing paragraph.

' + const plainText = htmlToPlainText(preserveConfluenceCallouts(html)) + expect(plainText).toContain('[WARNING] Do NOT use this form for: GitLab') + expect(plainText).toContain('Intro paragraph.') + expect(plainText).toContain('Trailing paragraph.') + } + ) + + it.concurrent( + 'does not fuse adjacent paragraph and list-item text together (word-boundary regression)', + () => { + const html = + '
' + + '
' + + '

Do NOT use this form for:

' + + '
  • GitLab
  • ServiceNow
' + + '
' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('for:GitLab') + expect(result).not.toContain('GitLabServiceNow') + expect(result).toContain('Do NOT use this form for: GitLab ServiceNow') + } + ) + + it.concurrent( + 'preserves word boundaries across multiple paragraphs in a generic Panel macro', + () => { + const html = + '
' + + '

First sentence.

Second sentence.

' + + '
' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('First sentence. Second sentence.') + expect(result).not.toContain('sentence.Second') + } + ) + + it.concurrent( + 'does not duplicate or fuse text from a nested list inside a callout body (nesting regression)', + () => { + const html = + '
' + + '
' + + '
  • Outer item' + + '
    • Nested item A
    • Nested item B
    ' + + '
  • Outer item two
' + + '
' + const result = preserveConfluenceCallouts(html) + // Each nested
  • 's text must appear exactly once, not duplicated by the + // outer
  • also being matched and its .text() recursing into it. + const occurrences = (result.match(/Nested item A/g) ?? []).length + expect(occurrences).toBe(1) + expect(result).not.toContain('Nested item ANested item B') + expect(result).toContain('Outer item Nested item A Nested item B Outer item two') + } + ) + + it.concurrent('does not fuse text from a blockquote nested inside a table cell', () => { + const html = + '
    ' + + '
    Cell text

    quoted text

    after quote
    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('quotedtext') + expect(result).not.toContain('textafter') + expect(result).toContain('Cell text quoted text after quote') + }) + + it.concurrent( + 'does not inject an artificial space into inline-formatted text mid-word (inline vs. block regression)', + () => { + const html = + '
    ' + + '

    This is unbelieveable.

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('un believe able') + expect(result).toContain('This is unbelieveable.') + } + ) + + it.concurrent('does not inject a space before punctuation carried by an inline tag', () => { + const html = + '
    ' + + '

    Do not proceed!

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('proceed !') + expect(result).toContain('[WARNING] Do not proceed!') + }) + + it.concurrent('keeps natural word spacing when inline tags wrap a whole word', () => { + const html = + '
    ' + + '

    Do NOT use this form.

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('Do NOT use this form.') + }) + + it.concurrent( + 'labels a nested panel-in-panel with both its own and its parent label (nesting regression)', + () => { + const html = + '
    Outer
    ' + + '
    Inner
    ' + + '

    inner body

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[CALLOUT: Outer]') + expect(result).toContain('[CALLOUT: Inner] inner body') + } + ) + + it.concurrent( + 'labels a nested info-macro inside a panel with its own type instead of dropping it', + () => { + const html = + '
    ' + + '
    ' + + '

    Do not use this.

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).toContain('[WARNING] Do not use this.') + } + ) + + it.concurrent( + "does not let an untitled outer panel adopt a nested panel's header as its own", + () => { + const html = + '
    ' + + '
    Inner title
    ' + + '

    inner body

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + // The outer panel has no header of its own — it must fall back to a + // bare [CALLOUT], not steal "Inner title" from the nested panel. + expect(result).toContain('[CALLOUT] [CALLOUT: Inner title] inner body') + } + ) + + it.concurrent('does not fuse text on either side of a
    line break', () => { + const html = + '
    ' + + '

    Do NOT use this form for:
    GitLab

    ' + + '
    ' + const result = preserveConfluenceCallouts(html) + expect(result).not.toContain('for:GitLab') + expect(result).toContain('[WARNING] Do NOT use this form for: GitLab') + }) +}) diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 47cf4f7a626..470cdc8ab68 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import * as cheerio from 'cheerio' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' @@ -8,6 +9,155 @@ import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/con const logger = createLogger('ConfluenceConnector') +/** Label prefixes for Confluence's built-in Info/Note/Warning/Tip macros, by their rendered CSS suffix. */ +const CALLOUT_LABELS: Record = { + information: '[INFO]', + note: '[NOTE]', + warning: '[WARNING]', + tip: '[TIP]', + error: '[ERROR]', +} + +/** + * Inline formatting tags whose text flows directly into their surrounding + * sentence with no implied word break — e.g. `unbelieveable` must stay + * `unbelievable`, and `Hello!` must stay `Hello!`, not gain an + * artificial space. Anything not in this set (p, li, td, div, headings, br, + * etc.) is treated as a block boundary that always implies a break, even when + * the source HTML has no literal whitespace there. + */ +const INLINE_FORMATTING_TAGS = new Set([ + 'b', + 'strong', + 'i', + 'em', + 'u', + 's', + 'strike', + 'del', + 'ins', + 'sup', + 'sub', + 'small', + 'mark', + 'code', + 'span', + 'a', + 'abbr', + 'cite', + 'q', + 'kbd', + 'var', + 'samp', + 'time', +]) + +/** + * Cheerio's `.text()` concatenates every descendant text node with no + * separator at all, so pulling a macro body's text in one call fuses adjacent + * blocks together (e.g. a `

    ...for:

    ` immediately followed by + * `
  • GitLab
  • ` becomes `for:GitLab`, corrupting the very word boundaries + * RAG chunking depends on). Simply joining every text node with a space isn't + * right either — that would corrupt genuinely inline-formatted text the same + * way. This walks the DOM, accumulating text through inline tags without a + * separator (preserving exact source adjacency) and flushing to a new segment + * at every other tag boundary (a block always implies a break, regardless of + * source whitespace) — matching how `html-parser.ts` already walks HTML for a + * related reason elsewhere in this codebase, extended with the inline/block + * distinction real Confluence rich text requires. + */ +function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio): string { + const parts: string[] = [] + let current = '' + + const flush = () => { + const text = current.trim() + if (text) parts.push(text) + current = '' + } + + const visit = ($node: cheerio.Cheerio) => { + $node.contents().each((_, child) => { + if (child.type === 'text') { + current += $(child).text() + } else if (child.type === 'tag') { + const tag = child.tagName?.toLowerCase() + if (tag && INLINE_FORMATTING_TAGS.has(tag)) { + visit($(child)) + } else { + flush() + visit($(child)) + flush() + } + } + }) + } + + visit($el) + flush() + return parts.join(' ').trim() +} + +/** Matches either flavor of panel/macro this function rewrites. */ +const MACRO_SELECTOR = 'div.confluence-information-macro, div.panel' + +/** + * Confluence's rendered `view` HTML wraps Info/Note/Warning/Tip macros in + * `confluence-information-macro confluence-information-macro-{type}` divs, and + * the customizable Panel macro in `.panel` > `.panelHeader` + `.panelContent` + * divs. `htmlToPlainText`'s blind tag-stripping discards the divs' classes along + * with the tags, so a red "do not use" warning panel becomes indistinguishable + * from a plain paragraph once flattened — and its trailing whitespace collapse + * would erase any newline-based separation too. Each detected panel is rewritten + * into a single bracketed label plus its own text so the callout semantic + * survives both the tag strip and the whitespace collapse. + * + * A panel can itself contain another panel or macro (e.g. a nested Note inside + * a Warning panel). Processing matches in document order — outermost first — + * would read a not-yet-converted nested macro as plain body text before it + * ever got its own label, silently dropping the inner callout's semantic, and + * `.find('.panelHeader')` would then risk pulling a nested panel's header up + * as if it were the outer panel's own title. Converting only "leaf" macros + * (ones with no remaining nested macro/panel inside them) and repeating until + * none are left processes innermost-first, so a nested macro is already a + * bracketed `

    ` by the time its parent's body/header text is read — at which + * point it correctly reads as plain text carrying its own label. + */ +export function preserveConfluenceCallouts(html: string): string { + if (!html) return html + + const $ = cheerio.load(html) + + let progressed = true + while (progressed) { + progressed = false + const leaves = $(MACRO_SELECTOR).filter((_, el) => $(el).find(MACRO_SELECTOR).length === 0) + if (leaves.length === 0) break + + leaves.each((_, el) => { + const $el = $(el) + if ($el.hasClass('confluence-information-macro')) { + const type = ($el.attr('class') ?? '') + .match(/confluence-information-macro-(\w+)/)?.[1] + ?.toLowerCase() + const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information + const macroBody = $el.find('.confluence-information-macro-body').first() + const body = extractBlockJoinedText($, macroBody.length > 0 ? macroBody : $el) + $el.replaceWith($('

    ').text(`${label} ${body}`)) + } else { + const headerText = extractBlockJoinedText($, $el.find('.panelHeader').first()) + const panelContent = $el.find('.panelContent').first() + const bodyText = extractBlockJoinedText($, panelContent.length > 0 ? panelContent : $el) + const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]' + $el.replaceWith($('

    ').text(`${label} ${bodyText}`)) + } + progressed = true + }) + } + + return $.html() +} + /** * Escapes a value for use inside CQL double-quoted strings. */ @@ -108,10 +258,12 @@ async function fetchLabelsForPages( * invalidates every previously-synced Confluence document so a one-time * re-hydration picks up content newly reachable by the current extraction * (e.g. the switch from `storage` to rendered `view`, which expands Include - * Page / Excerpt macros). Without it, already-indexed pages whose version is - * unchanged classify as `unchanged` and keep their stale (empty) content. + * Page / Excerpt macros; or `preserveConfluenceCallouts`, which stops + * flattening panel/info/note/warning/tip macros into indistinguishable plain + * text). Without it, already-indexed pages whose version is unchanged + * classify as `unchanged` and keep their stale (pre-fix) content. */ -const CONTENT_REPRESENTATION = 'view' +const CONTENT_REPRESENTATION = 'view-callouts' /** * Produces a canonical metadata stub with a deterministic contentHash that @@ -288,7 +440,7 @@ export const confluenceConnector: ConnectorConfig = { const body = page.body as Record | undefined const view = body?.view as Record | undefined const rawContent = (view?.value as string) || '' - const plainText = htmlToPlainText(rawContent) + const plainText = htmlToPlainText(preserveConfluenceCallouts(rawContent)) const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)]) const labels = labelMap.get(String(page.id)) ?? [] diff --git a/apps/sim/connectors/google-sheets/google-sheets.test.ts b/apps/sim/connectors/google-sheets/google-sheets.test.ts index df1e6c898e4..66eb84aa434 100644 --- a/apps/sim/connectors/google-sheets/google-sheets.test.ts +++ b/apps/sim/connectors/google-sheets/google-sheets.test.ts @@ -116,40 +116,26 @@ describe('googleSheetsConnector trashed handling', () => { }) describe('listDocuments', () => { - it('returns an empty listing and confirms the empty result when the spreadsheet is trashed', async () => { + it('returns an empty listing when the spreadsheet is trashed', async () => { stubFetch({ drive: { status: 200, body: { trashed: true, modifiedTime: '2026-07-01T00:00:00.000Z' } }, }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result).toEqual({ documents: [], hasMore: false }) - expect(syncContext.sourceConfirmedEmpty).toBe(true) }) it('lists every tab when the trashed field is absent', async () => { stubFetch({ drive: { status: 200, body: { modifiedTime: '2026-07-01T00:00:00.000Z' } } }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result.documents.map((d) => d.externalId)).toEqual([ `${SPREADSHEET_ID}__sheet__0`, `${SPREADSHEET_ID}__sheet__7`, ]) expect(result.hasMore).toBe(false) - expect(syncContext.sourceConfirmedEmpty).toBeUndefined() }) it('lists every tab when trashed is explicitly false', async () => { @@ -162,20 +148,13 @@ describe('googleSheetsConnector trashed handling', () => { it('fails open and lists every tab when the Drive read fails', async () => { stubFetch({ drive: { status: 500, body: { error: 'backend error' } } }) - const syncContext: Record = {} - const result = await googleSheetsConnector.listDocuments( - ACCESS_TOKEN, - SOURCE_CONFIG, - undefined, - syncContext - ) + const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) expect(result.documents.map((d) => d.externalId)).toEqual([ `${SPREADSHEET_ID}__sheet__0`, `${SPREADSHEET_ID}__sheet__7`, ]) - expect(syncContext.sourceConfirmedEmpty).toBeUndefined() }) it('fails open when the Drive body is not an object', async () => { @@ -185,14 +164,6 @@ describe('googleSheetsConnector trashed handling', () => { expect(result.documents).toHaveLength(2) }) - - it('does not throw when trashed and no syncContext is passed', async () => { - stubFetch({ drive: { status: 200, body: { trashed: true } } }) - - const result = await googleSheetsConnector.listDocuments(ACCESS_TOKEN, SOURCE_CONFIG) - - expect(result).toEqual({ documents: [], hasMore: false }) - }) }) describe('getDocument', () => { diff --git a/apps/sim/connectors/google-sheets/google-sheets.ts b/apps/sim/connectors/google-sheets/google-sheets.ts index 11657e7842e..746bdb2bfa9 100644 --- a/apps/sim/connectors/google-sheets/google-sheets.ts +++ b/apps/sim/connectors/google-sheets/google-sheets.ts @@ -253,7 +253,7 @@ export const googleSheetsConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record, _cursor?: string, - syncContext?: Record + _syncContext?: Record ): Promise => { const spreadsheetId = (sourceConfig.spreadsheetId as string)?.trim() if (!spreadsheetId) { @@ -269,18 +269,14 @@ export const googleSheetsConnector: ConnectorConfig = { /** * A trashed spreadsheet is no longer current content, so it drops out of the - * listing and stops being re-indexed. Unlike an ordinary empty listing page - * (which could equally mean the source is unreachable), this is a direct, - * single-resource confirmation from the Drive API that the spreadsheet itself - * is gone — so `sourceConfirmedEmpty` tells the sync engine's zero-document - * guard it's safe to reconcile (purge the stored tabs) on this sync, rather - * than requiring a forced full resync. `validateConfig` reports the trashed - * state so the connector does not look healthy while serving tabs from a file - * its owner has thrown away. + * listing and stops being re-indexed. The sync engine reconciles its absence + * the same way it does for every connector: pending-removal on the first + * sync that doesn't see it, purged once a later sync confirms it's still + * gone. `validateConfig` reports the trashed state so the connector does not + * look healthy while serving tabs from a file its owner has thrown away. */ if (isTrashedDriveFile(driveMetadata)) { logger.info('Spreadsheet is in the Drive trash; listing no documents', { spreadsheetId }) - if (syncContext) syncContext.sourceConfirmedEmpty = true return { documents: [], hasMore: false } } diff --git a/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx new file mode 100644 index 00000000000..90be869f995 --- /dev/null +++ b/apps/sim/content/library/best-ai-agents-for-customer-support-automation/index.mdx @@ -0,0 +1,145 @@ +--- +slug: best-ai-agents-for-customer-support-automation +title: 'Best AI Agents for Customer Support Automation' +description: 'Compare the best AI agents for customer support automation across ticket triage, feedback-to-ticket workflows, inbox management, knowledge grounding, integrations, and self-hosting.' +date: 2026-07-23 +updated: 2026-07-23 +authors: + - andrew +readingTime: 14 +tags: [AI Agents, Customer Support, Support Automation, Sim] +ogImage: /library/best-ai-agents-for-customer-support-automation/cover.jpg +canonical: https://www.sim.ai/library/best-ai-agents-for-customer-support-automation +draft: false +faq: + - q: "What is the best AI agent platform for customer support automation?" + a: "Sim is the best fit for teams that want an open-source, self-hostable workspace with native Knowledge Bases, helpdesk integrations, and API, Chat, and MCP deployment options. Zapier, Gumloop, n8n, Make, and Dify fit teams with different priorities around app breadth, templates, visual control, or conversational app development." + - q: "Can AI agents automate ticket triage and routing?" + a: "Yes. An AI agent can classify a ticket by topic and urgency, assign a priority, and route clear cases to the right queue. Ambiguous or low-confidence cases should go to a human review queue." + - q: "Can AI agents convert customer feedback into support tickets?" + a: "Yes. An agent can extract the intent, sentiment, category, priority, and summary from reviews, surveys, or support channels, then create a structured ticket in a connected helpdesk." + - q: "How do AI agents automate support inbox management?" + a: "They read and classify incoming messages, draft grounded replies from support documentation and macros, and either send the response or escalate it for human approval according to the workflow's risk rules." +--- + +## TL;DR + +Sim leads for teams that want an open-source, self-hostable AI workspace for support automation, with runner-ups that fit specific buyer types. + +- **Sim** wins for open-source, Apache 2.0 self-hosting with native Knowledge Bases and multi-surface deployment. +- **Zapier** fits teams that need the largest app catalog and standardized ease of use. +- **Gumloop** suits ops-led teams that want fast setup through templates. +- **n8n** works for technical teams wanting node-based control. +- **Make** fits teams building visual, branching workflow logic. +- **Dify** serves teams building LLM-first conversational apps. + +This article covers three support automation use cases: ticket triage and routing, converting customer feedback into tickets, and support inbox management. + +## What is the best AI agent platform for customer support automation? + +Sim is the best AI agent platform for customer support automation when you want an open-source workspace you can actually control. It ships under the Apache 2.0 license, so you can run it as a hosted cloud product at [sim.ai](https://sim.ai) or self-host the same stack via Docker or Kubernetes without commercial-use restrictions. You build agents by describing what you want in plain language through Mothership, and you ground them in your own docs and macros using native Knowledge Bases. What separates Sim from single-surface tools is the deployment step. You publish one workflow as an API, a hosted chat interface, or an MCP tool, so the same triage agent can answer inside a chat window and serve another system through an endpoint. + +That grounding matters because a support agent is only as accurate as the material it reads. A Knowledge Base of your help center articles, refund policies, and canned macros lets the agent answer from your actual rules instead of guessing. + +Sim also connects to the helpdesk tools your team already runs, which removes the custom API glue that usually stalls these projects. The Zendesk integration handles ticket, user, and organization management, and the Intercom integration covers contacts, companies, conversations, and tickets. The Jira Service Management integration handles internal IT-style ticketing. With more than 1,000 integrations, you wire an agent to your CRM and helpdesk in the builder rather than in code. + +The runner-ups each win a specific buyer. Zapier is the safe pick when you want the largest app catalog and your ops team already lives inside it. Gumloop fits ops-led teams that want fast results from a template library. n8n suits technical teams that want node-based control and a mature self-hosted engine. Make works for teams that need visual, branching workflow logic without writing much code. Dify earns a mention for teams building LLM-first conversational apps rather than broad automation. Each section below argues its case in depth, so read on for the case behind each. + +## Can AI agents automate ticket triage and routing? + +Yes, AI agents automate ticket triage and routing by classifying incoming tickets, scoring their priority, and applying routing logic that pushes each ticket to the right queue or agent. A well-built agent reads the ticket body, identifies the topic and urgency, and decides where it belongs before a human ever opens it. The routing decision runs on the same helpdesk and CRM tools your team already uses, so a billing complaint lands with the billing team and an outage report escalates to on-call. + +The accuracy of that decision depends on what the agent knows. Keyword rules break because they match surface text without understanding intent, so a ticket that says "I can't get in" routes wrong when the underlying issue is a password reset. Sim solves this by grounding the agent in a Knowledge Base of your product docs, past resolutions, and support macros, which lets the agent reason about what the customer actually needs rather than which words they typed. Wire that Knowledge Base to Sim's Zendesk and Intercom integrations, and the agent classifies the ticket against real product knowledge, then writes the priority and routing decision straight back into the helpdesk record. + +Triage still breaks on ambiguous tickets, and honest teams plan for it. A message that mixes two unrelated problems, or one written in a language your Knowledge Base doesn't cover well, produces a low-confidence classification the agent should not act on alone. Route those edge cases to a human review queue instead of forcing a guess, and set a confidence threshold below which the agent flags rather than routes. That threshold keeps automation fast on clear tickets while protecting the customers whose problems don't fit a clean category. + +## Can AI agents convert customer feedback into tickets? + +Yes, AI agents convert raw customer feedback into structured helpdesk tickets, and they do it by extracting intent from unstructured text before writing a clean record a human can act on. An agent ingests feedback from post-support surveys, app store reviews, or a shared support channel, reads the sentiment and the underlying request, then creates a ticket in your helpdesk with a category, priority, and summary already filled in. + +The quality of that auto-created ticket depends on two things: how well the agent understands your product and how deeply it connects to your helpdesk. Knowledge grounding decides whether the agent classifies a vague complaint correctly or files it under the wrong queue. An agent grounded in your docs and macros knows that "the export keeps timing out" belongs to the billing-export bug queue, not general feedback. Without that grounding, you get a ticket a human has to re-triage, which defeats the point. + +Integration depth decides whether the ticket lands usable or half-formed. A shallow connector might create a ticket with a title and nothing else. Sim's Zendesk tools can read and write ticket, user, organization, priority, tag, and custom-field data, so a workflow can carry customer and account context into the record instead of leaving an agent with a blank screen. + +Consider a one-star review that mentions a broken checkout flow. A Sim workflow can read the review through the feedback channel, check it against a Knowledge Base of known issues, match it to an open bug, and create a Zendesk ticket tagged with the affected feature and a priority score, then link it to the existing bug record. A human agent opens that ticket and already knows what happened and where it fits. + +## How do AI agents automate support inbox management? + +An AI agent handles a support inbox by reading each incoming message, classifying it, drafting a reply from your macros and documentation, and either sending it or flagging it for a human. The classification step sorts messages by intent and urgency. The drafting step pulls the right macro or doc passage and writes it into a coherent response. The escalation step decides which conversations a person needs to see before anything goes out. + +Where this automation actually lives depends on the deployment surface you choose. Deploy the agent as a hosted chat interface and it becomes the front door that answers customers directly. Deploy it as an API and it plugs into the inbox tool you already run, drafting inside Zendesk or Intercom rather than replacing them. Deploy it as an MCP tool and another system calls the agent when it needs a support answer. That deployment surface decides whether the agent sits on top of your existing stack or in front of it. + +Sim's Zendesk and Intercom integrations make the read-draft-write loop concrete. The agent reads incoming tickets and conversations through the integration, drafts a reply grounded in a Knowledge Base of macros and support docs, and writes the response or an escalation note back into the same helpdesk record. Grounding matters here because a reply built from your actual macros stays accurate, while a raw model guess drifts. + +Whether you choose draft-and-approve or full autonomy separates most buyers. A draft-and-approve setup writes the reply and leaves it for an agent to send, which suits teams protecting tone and accuracy. Full autonomy sends without review, which fits high-volume, low-risk queries where speed outweighs oversight. + +## n8n for technical teams building custom support workflows + +n8n is the pick for technical teams that want node-based control over every branch of a support workflow. Its execution engine has matured over years of production use, and its node library covers hundreds of services with the granular parameter control that engineers expect. When you need a support automation with custom error handling, conditional retries, and precise data transformations between a helpdesk and a CRM, n8n gives you the primitives to build exactly what you want. + +The template ecosystem shortens the path from blank canvas to working flow. You can pull a community workflow for ticket enrichment or Slack escalation, then rewire it to your stack rather than starting from scratch. For a team comfortable reading and editing node graphs, that flexibility pays off across every automation you build after the first. + +Hosting is not what separates n8n from Sim, since both offer a managed cloud product and a self-hosted path you run in your own infrastructure. The license is the real difference. n8n ships under the Sustainable Use License, a fair-code model that restricts certain commercial uses and hosting-as-a-service arrangements. Sim ships under Apache 2.0, a fully permissive license that lets you run, modify, and commercialize the code without those commercial-use restrictions. If your legal team needs a clean permissive license, that distinction decides the choice before you write a single workflow. + +The main concession is the build curve. n8n provides native nodes for assembling a RAG pipeline, but you still configure the document loading, embeddings, vector store, and retrieval logic that grounds a triage agent in your documentation. Sim ships purpose-built, workspace-level Knowledge Bases for that grounding and lets you describe the agent in plain language through Mothership, so a support engineer reaches a working, doc-grounded agent with less assembly. Pick n8n when you want maximum control and are willing to build the grounding pipeline. Pick Sim when you want that layer ready out of the box. + +## Zapier for teams that want the largest app catalog + +Zapier wins on catalog breadth, and that alone explains why so many support teams default to it. With more than 9,000 app connections, Zapier almost certainly already talks to the tools your support stack runs, whether that means Zendesk, Salesforce, Slack, or a survey tool nobody else supports. When your goal is wiring one event to another action across a sprawling SaaS stack, Zapier's coverage means you rarely hit a dead end where an integration simply doesn't exist. + +That breadth pairs with a build model most ops people can pick up in an afternoon. Zapier's trigger-and-action Zaps read like plain sentences, and a support-ops lead with no coding background can ship a working automation the same day. For teams already standardized on Zapier across marketing and sales ops, adding a few support automations costs almost nothing in learning curve. + +Zapier's limits show up the moment your triage logic gets complicated. Zapier's agent capabilities and knowledge grounding stay shallow compared to a purpose-built AI workspace, so an agent that needs to read a ticket, weigh it against your documented policies, and route it by nuanced intent is harder to express as a linear Zap. You can end up chaining filters and paths that grow brittle as edge cases pile up, because Zapier was built to move data between apps before it added agents. + +Zapier Agents and Chatbots can attach FAQs, documents, tables, and webpages as knowledge sources. Those sources are configured per agent rather than managed as a reusable, workspace-level Knowledge Base, so grounding multiple support workflows can mean copying and maintaining context across agents. Pick Zapier when your priority is connecting the widest possible set of tools with minimal setup, and reach for an AI workspace when reusable grounding and nuanced routing matter more than the length of the connector list. + +## Make for visual, multi-step support automations + +Make earns its place when your support workflows branch in ways a linear tool can't express. Its scenario builder lets you draw conditional paths visually, so a ticket that meets one condition routes one way and a ticket that fails it takes another. You can nest routers, add filters between modules, and build error-handling branches without writing code. For a support team mapping out a triage flow with a dozen possible outcomes, that visual model is easier to reason about than a script or a flat rule list. + +The scenario builder shines on the operations side of support automation. You can pull a new Zendesk ticket, check its priority and tags, split the flow across several routes, and post to different Slack channels or update different CRM records depending on what each branch decides. When your logic depends on many overlapping conditions, seeing the whole tree on one canvas beats debugging it in text. + +Make's weakness surfaces once the agent itself needs to reason across reusable workspace knowledge rather than follow rules you drew. Make's Knowledge feature can ground an AI Agent with uploaded context files backed by RAG, but that context remains attached to the agent rather than becoming a shared Knowledge Base that workflows across the workspace can reuse. The scenario still requires you to arrange the surrounding retrieval, routing, and helpdesk actions as modules. + +That difference defines who Make fits. If your support automation is mostly deterministic routing with occasional AI classification, Make handles it cleanly. If you want a workspace-level knowledge layer that multiple agents use to read a ticket, retrieve the right macro, and draft a grounded reply, Sim's Knowledge Bases and Mothership building target that case directly. + +## Gumloop for ops teams automating support workflows with templates + +Gumloop earns its place for ops-led teams that measure success in weeks, not months. Its template library ships with pre-built support workflows you can clone and adjust, so an operations lead can stand up a triage-to-routing flow without hiring an engineer or learning a node graph from scratch. For support-ops buyers specifically, that head start matters, because most of them own the ticketing process but not the codebase. + +The template approach shapes the whole product. Gumloop's UX assumes you want to configure existing patterns rather than design new ones, and it rewards that assumption with clean workflows and quick wins for common tasks like feedback intake, tagging, and handoffs to a helpdesk. If your team already knows the shape of the automation you need and just wants it running, Gumloop gets you there faster than tools that make you design from a blank canvas. + +That same design choice sets the ceiling. Gumloop does not give you the self-hosting control that regulated or security-conscious teams need, and it does not expose the custom agent design you would build if your support logic outgrows the template it started from. When your routing rules stop fitting a pre-made pattern, you hit the edge of what the platform wants you to do. + +For teams that need to run everything inside their own infrastructure or wire up bespoke agent behavior, Sim offers Apache 2.0 self-hosting and Knowledge Bases that ground agents in your own docs and macros. Gumloop wins on speed for standard ops workflows. Sim wins when the workflow has to be yours, hosted where you choose and built to logic no template anticipated. Match the tool to how far your support automation will eventually stretch. + +## Dify as a secondary option for LLM-native teams + +Dify earns a spot on this list if you're building an LLM-first conversational app rather than automating a broad support ops stack. Its prompt and app management tooling is genuinely strong, and it gives you a clean workspace for versioning prompts, testing model outputs, and shipping a chat-style agent grounded in your own content. For a support team whose main goal is a single conversational assistant, that focus pays off. + +The tradeoff shows up the moment you need to reach into the tools your support team already runs. Dify's integration breadth trails the workflow-automation platforms above it, so wiring an agent into Zendesk, Intercom, or a CRM takes more custom work than it does on Sim or Zapier. If your support automation lives mostly inside chat and rarely touches other systems, Dify handles it well. If a ticket has to flow through triage, routing, and a helpdesk record, you'll feel the gaps, and a platform built around integrations and grounding fits better. + +## How the top AI agent platforms for customer support compare + +The six platforms below split along a clear line. Some optimize for broad integration reach and template speed, and others give you agent depth and self-hosting control. Read the table across these axes to match a platform to how your support team actually works, not to a generic feature count. + +| Platform | Builder model | Agent depth | Knowledge grounding | Integrations | Deployment surfaces | License / hosting | Pricing model | Best-fit ICP | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Sim | Natural-language (Mothership) + visual | Deep, multi-step agents | Native Knowledge Bases | 1,000+ | API, hosted chat interface, MCP tool | Apache 2.0, cloud or self-host | Usage-based tiers | Teams wanting open-source AI workspace | +| n8n | Node-based visual | Moderate, DIY assembly | Native RAG nodes, configurable pipeline | 1,900+ listed | API, webhook | Sustainable Use License, cloud or self-host | Execution-based | Technical teams needing node control | +| Zapier | Linear step builder | Moderate | Per-agent knowledge sources | 9,000+ | Webhook, embed | Proprietary, cloud only | Task-based | Ops teams standardized on Zapier | +| Make | Visual scenario builder | Moderate | Agent Knowledge with RAG | 3,000+ | Webhook, API | Proprietary, cloud only | Operations-based | Teams needing branching visual logic | +| Gumloop | Template-driven visual | Moderate | Basic | 100+ | Webhook, API | Proprietary, cloud only | Credit-based | Ops-led teams wanting templates | +| Dify | LLM-app builder | Deep for chat | Native RAG | Narrower | API, embed widget | Open-source, cloud or self-host | Usage-based | LLM-first app teams | + +## Which AI agent platform fits your team? + +Your best pick depends on what your team controls and where the workflow needs to live. + +Technical teams that need to self-host and own the code should compare Sim and n8n directly. Both offer cloud and self-hosted paths, so the license decides between them. Sim ships under Apache 2.0 with no commercial-use restrictions and gives you native Knowledge Bases plus Mothership natural-language building, which removes much of the RAG assembly n8n's node-based engine requires for grounded support agents. Choose n8n when you want granular node-level control and already have engineers comfortable configuring their own retrieval logic. + +Ops-led teams optimizing existing workflows should start with Zapier or Gumloop. Zapier wins when your stack already spans dozens of tools and you want the broadest catalog to connect them. Gumloop wins when you want support-specific templates that get a triage or feedback-to-ticket flow running quickly. Both prioritize setup speed, so compare them with Sim when your triage logic needs reusable workspace knowledge and deeper agent control. + +Enterprise teams that need governance and scale should weigh Sim's self-hosting against their own compliance requirements. Running the workspace inside your own infrastructure keeps customer conversations and Knowledge Base contents on hardware you control, and deploying the same workflow as an API, hosted chat interface, or MCP tool lets one governed agent serve multiple support surfaces without duplicate builds. + +Start where the friction is lowest. Open a hosted account at [sim.ai](https://sim.ai) to start building a triage agent, or self-host through Docker if your policy requires it. Gumloop's template library is the fastest route if you want a working support flow before you commit to a full build. diff --git a/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts b/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts new file mode 100644 index 00000000000..397b22043e8 --- /dev/null +++ b/apps/sim/lib/core/security/guarded-request-fetch.server.test.ts @@ -0,0 +1,232 @@ +/** + * @vitest-environment node + * + * Covers the `undici.request()`-backed guarded fetch: `createSsrfGuardedFetchWithDispatcher` + * builds its `fetch` on `undici.request` (not `undici.fetch`) because undici's `fetch` never + * delivers a streaming `response.body` under the Bun runtime the server runs on. These tests + * drive the real builder with a mocked `undici.request` and assert the constructed `Response` + * preserves status/headers/url, streams its body, follows redirects via `followRedirectsGuarded`, + * serves buffered reads, and settles the reader when the source is destroyed without an error. + */ +import { Readable } from 'node:stream' +import { gzipSync } from 'node:zlib' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAgent, mockUndiciRequest } = vi.hoisted(() => { + class MockAgent { + close() { + return Promise.resolve() + } + destroy() { + return Promise.resolve() + } + } + return { mockAgent: MockAgent, mockUndiciRequest: vi.fn() } +}) + +vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest, fetch: vi.fn() })) + +declare module '@/lib/core/security/input-validation.server?guarded-request-test' { + // biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier + export * from '@/lib/core/security/input-validation.server' +} + +import { createSsrfGuardedFetchWithDispatcher } from '@/lib/core/security/input-validation.server?guarded-request-test' + +/** A byte stream that yields a single `Buffer` chunk then ends — mirrors undici's body. */ +function byteStream(text: string): Readable { + const stream = new Readable({ read() {} }) + stream.push(Buffer.from(text)) + stream.push(null) + return stream +} + +function undiciReply( + statusCode: number, + headers: Record, + body: Readable +) { + return { statusCode, headers, body, trailers: {}, opaque: null, context: {} } +} + +describe('createSsrfGuardedFetchWithDispatcher (undici.request backed)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs a Response with the reply status, headers, url, and a streaming body', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'text/event-stream', 'mcp-session-id': 's-1' }, + byteStream('event: message\ndata: {"id":1}\n\n') + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/serve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"jsonrpc":"2.0"}', + }) + + expect(response.status).toBe(200) + expect(response.headers.get('mcp-session-id')).toBe('s-1') + expect(response.url).toBe('https://mcp.example.com/serve') + + const text = await response.text() + expect(text).toContain('"id":1') + + // Does NOT auto-follow redirects — followRedirectsGuarded drives them instead. + expect(mockUndiciRequest).toHaveBeenCalledTimes(1) + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.method).toBe('POST') + expect(options.headers).toEqual({ 'content-type': 'application/json' }) + expect(options.body).toBe('{"jsonrpc":"2.0"}') + expect(options.maxRedirections).toBeUndefined() + }) + + it('follows a redirect through followRedirectsGuarded and reports the final url', async () => { + mockUndiciRequest + .mockResolvedValueOnce( + undiciReply(302, { location: 'https://mcp.example.com/final' }, byteStream('redirect')) + ) + .mockResolvedValueOnce(undiciReply(200, {}, byteStream('final-body'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/start', { method: 'GET' }) + + expect(mockUndiciRequest).toHaveBeenCalledTimes(2) + expect(response.status).toBe(200) + expect(response.url).toBe('https://mcp.example.com/final') + expect(await response.text()).toBe('final-body') + }) + + it('supports buffered reads (.json()) through the constructed body', async () => { + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'application/json' }, + byteStream(JSON.stringify({ ok: true })) + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/data', { method: 'GET' }) + + expect(await response.json()).toEqual({ ok: true }) + }) + + it('normalizes a Headers instance and an ArrayBuffer body for undici.request', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('x'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://mcp.example.com/x', { + method: 'POST', + headers: new Headers({ authorization: 'Bearer t' }), + body: new TextEncoder().encode('payload').buffer, + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.headers).toEqual({ authorization: 'Bearer t' }) + expect(Buffer.isBuffer(options.body)).toBe(true) + expect(Buffer.from(options.body).toString()).toBe('payload') + }) + + it('serializes a URLSearchParams body and defaults the form content-type (OAuth token exchange)', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://auth.example.com/token', { + method: 'POST', + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'r-1' }), + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.body).toBe('grant_type=refresh_token&refresh_token=r-1') + expect(options.headers['content-type']).toBe('application/x-www-form-urlencoded;charset=UTF-8') + }) + + it('does not override an explicit content-type on a URLSearchParams body', async () => { + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}'))) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + await fetch('https://auth.example.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code' }), + }) + + const [, options] = mockUndiciRequest.mock.calls[0] + expect(options.body).toBe('grant_type=authorization_code') + expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + expect(options.headers['content-type']).toBeUndefined() + }) + + it('copies each chunk so a recycled source buffer cannot corrupt queued data', async () => { + const source = new Readable({ read() {} }) + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source)) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/stream', { method: 'GET' }) + const reader = response.body!.getReader() + + // Emit a chunk, then mutate the SAME backing buffer (as undici's pool reuse would). + const buf = Buffer.from('AB') + source.push(buf) + const first = await reader.read() + buf[0] = 0x00 // corrupt the source buffer after the chunk was enqueued + expect(Buffer.from(first.value!).toString()).toBe('AB') // copy is unaffected + source.push(null) + await reader.read() + }) + + it('decodes a gzip Content-Encoding body and strips the framing headers (fetch parity)', async () => { + const gzipped = gzipSync(Buffer.from(JSON.stringify({ ok: true, msg: 'compressed' }))) + const source = new Readable({ read() {} }) + source.push(gzipped) + source.push(null) + mockUndiciRequest.mockResolvedValueOnce( + undiciReply( + 200, + { 'content-type': 'application/json', 'content-encoding': 'gzip', 'content-length': '40' }, + source + ) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/data', { method: 'GET' }) + + expect(await response.json()).toEqual({ ok: true, msg: 'compressed' }) + expect(response.headers.get('content-encoding')).toBeNull() + expect(response.headers.get('content-length')).toBeNull() + }) + + it('rejects the reader (not crash) when Content-Encoding is gzip but the body is not', async () => { + const source = new Readable({ read() {} }) + source.push(Buffer.from('this is definitely not gzip')) + source.push(null) + mockUndiciRequest.mockResolvedValueOnce( + undiciReply(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' }, source) + ) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/bad', { method: 'GET' }) + + // The zlib error must surface as a rejected read, never an unhandled 'error' event. + await expect(response.text()).rejects.toThrow() + }) + + it('rejects the reader when the source is destroyed without an error (abort/reset)', async () => { + const source = new Readable({ read() {} }) // stays open, never pushes + mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source)) + const { fetch } = createSsrfGuardedFetchWithDispatcher() + + const response = await fetch('https://mcp.example.com/hang', { method: 'GET' }) + const reader = response.body!.getReader() + const read = reader.read() + source.destroy() // no error argument — mirrors an aborted/reset socket + + await expect(read).rejects.toThrow(/closed before completing/) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 17de7aa63c8..c8a632e167f 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' +import zlib from 'node:zlib' import dns from 'dns/promises' import http from 'http' import https from 'https' @@ -8,7 +10,13 @@ import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' -import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' +import { + Agent, + type Dispatcher, + type RequestInit as UndiciRequestInit, + fetch as undiciFetch, + request as undiciRequest, +} from 'undici' import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -619,6 +627,259 @@ export async function followRedirectsGuarded( } } +/** Coerce a DOM/undici `HeadersInit` into the record shape undici `request` accepts. */ +function toUndiciRequestHeaders( + headers: UndiciRequestInit['headers'] +): Record | undefined { + if (!headers) return undefined + const record: Record = {} + if (Array.isArray(headers)) { + for (const [key, value] of headers as [string, string][]) { + if (value != null) record[key] = String(value) + } + return record + } + // Single cast (no `as unknown`): the optional `forEach` is satisfiable by both a plain + // record (absent) and a `Headers` instance (present), so it detects the iterable form. + const iterableHeaders = headers as { + forEach?: (cb: (value: string, key: string) => void) => void + } + if (typeof iterableHeaders.forEach === 'function') { + iterableHeaders.forEach((value, key) => { + record[key] = value + }) + return record + } + for (const [key, value] of Object.entries(headers as Record)) { + if (value != null) record[key] = Array.isArray(value) ? value.join(', ') : String(value) + } + return record +} + +/** Coerce a DOM/undici body init into a value undici `request` accepts. */ +function toUndiciRequestBody( + body: UndiciRequestInit['body'] +): string | Buffer | Uint8Array | Readable | undefined { + if (body == null) return undefined + // fetch accepts URLSearchParams (form-encoded) and undici.request does not — the MCP SDK's + // OAuth token/refresh exchange sends one. Serialize it to its wire form. + if (body instanceof URLSearchParams) return body.toString() + if (body instanceof ArrayBuffer) return Buffer.from(body) + if (ArrayBuffer.isView(body) && !(body instanceof Uint8Array)) { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength) + } + if (typeof (body as ReadableStream).getReader === 'function') { + // double-cast-allowed: DOM ReadableStream and the node:stream Web type differ but are structurally compatible at runtime + return Readable.fromWeb(body as unknown as Parameters[0]) + } + // string, Uint8Array/Buffer, or Readable — passed through unchanged. + // double-cast-allowed: undici BodyInit is wider than what request() accepts; our guarded/pinned callers only send these + return body as unknown as string | Buffer | Uint8Array | Readable +} + +/** + * Decompression transform for a `Content-Encoding`, or `null` to pass the body through. + * `undici.fetch` decodes the body automatically; `undici.request` does not, so this restores + * fetch parity for gzip/deflate/br responses (common behind CDNs). Unknown/absent encodings + * pass through untouched. + */ +function contentEncodingDecoder( + encoding: string +): zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null { + switch (encoding) { + case 'gzip': + case 'x-gzip': + return zlib.createGunzip() + case 'deflate': + return zlib.createInflate() + case 'br': + return zlib.createBrotliDecompress() + default: + return null + } +} + +/** + * Bridges an undici `request()` Node `Readable` into a WHATWG `ReadableStream` for a + * `Response` body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an + * unhandled `ERR_INVALID_STATE` ("Controller is already closed") when the web stream is + * cancelled while the Node stream is still flowing — which `followRedirectsGuarded` does + * on every redirect hop (`response.body.cancel()`). This bridge instead swallows a late + * enqueue after close and destroys the source on cancel, so cancelling a live body frees + * its socket cleanly. `maxResponseSize` overruns surface as the source's `error` event and + * reject the read, preserving the DoS backstop. + */ +function nodeReadableToWebStream(nodeStream: Readable): ReadableStream { + let settled = false + return new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk: Buffer) => { + try { + // Copy, not a view: undici may recycle the pooled buffer backing `chunk` after + // this handler returns, which would corrupt a chunk still queued for a slow + // consumer. `new Uint8Array(chunk)` allocates a fresh backing buffer. + controller.enqueue(new Uint8Array(chunk)) + } catch { + // Controller already closed (consumer cancelled) — stop the source, drop the chunk. + nodeStream.destroy() + return + } + if ((controller.desiredSize ?? 1) <= 0) nodeStream.pause() + }) + nodeStream.once('end', () => { + settled = true + try { + controller.close() + } catch {} + }) + nodeStream.once('error', (err) => { + settled = true + try { + controller.error(err) + } catch {} + }) + // An abort (signal) or upstream reset can `destroy()` the source with no `error` + // event; without this the reader would hang forever. `close` fires after every + // terminal path, so only act when `end`/`error` didn't already settle the stream. + nodeStream.once('close', () => { + if (settled) return + settled = true + try { + controller.error(new Error('MCP transport stream closed before completing')) + } catch {} + }) + // Start paused so nothing buffers before the consumer pulls (backpressure). + nodeStream.pause() + }, + pull() { + nodeStream.resume() + }, + cancel(reason) { + nodeStream.destroy(reason instanceof Error ? reason : undefined) + }, + }) +} + +/** + * Streaming-safe replacement for `undiciFetch(url, { ...init, dispatcher })`. + * + * undici's `fetch` exposes the response body as a WHATWG `ReadableStream` whose + * bridge is broken under the Bun runtime (which the standalone server runs on): + * response headers arrive but `response.body` never yields data, hanging every + * incremental read — MCP SSE `tools/list`, provider streaming — to its timeout. + * undici's lower-level `request()` returns a Node `Readable` instead, which Bun + * implements natively and streams correctly; `Readable.toWeb` bridges it back to + * a spec `Response`. Buffered reads (`.json()`/`.text()`/`.arrayBuffer()`) behave + * identically on both runtimes, so this is a drop-in substitute. + * + * SSRF is unchanged: the same `dispatcher` (Agent carrying the guarded/pinned + * `connect.lookup`) governs every connection, and `maxResponseSize` still caps the + * body. Redirects are NOT followed here (`maxRedirections: 0`); the caller drives + * them via {@link followRedirectsGuarded}, exactly as it did over `fetch`'s + * `redirect: 'manual'`. + */ +async function undiciRequestAsResponse( + input: RequestInfo | URL, + init: RequestInit, + dispatcher: Agent +): Promise { + let url: string + let effectiveInit = init as UndiciRequestInit + if (typeof Request !== 'undefined' && input instanceof Request) { + // A Request input carries its own method/headers/body/signal; lift them (explicit + // init fields win, per fetch semantics) so a guarded POST isn't downgraded to GET. + const bodyAllowed = input.method !== 'GET' && input.method !== 'HEAD' + effectiveInit = { + method: input.method, + headers: input.headers, + body: bodyAllowed ? await input.clone().arrayBuffer() : undefined, + signal: input.signal, + ...(init as UndiciRequestInit), + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + } as unknown as UndiciRequestInit + url = input.url + } else { + url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + } + + const method = (effectiveInit.method ?? 'GET').toUpperCase() + const canHaveBody = method !== 'GET' && method !== 'HEAD' + const requestHeaders = toUndiciRequestHeaders(effectiveInit.headers) ?? {} + const requestBody = canHaveBody ? toUndiciRequestBody(effectiveInit.body) : undefined + // fetch auto-adds a form content-type for a URLSearchParams body; preserve that parity + // when the caller didn't set one (the MCP SDK does set it explicitly, but not every caller). + if ( + canHaveBody && + effectiveInit.body instanceof URLSearchParams && + !Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type') + ) { + requestHeaders['content-type'] = 'application/x-www-form-urlencoded;charset=UTF-8' + } + const { statusCode, headers, body } = await undiciRequest(url, { + method: method as Dispatcher.HttpMethod, + headers: requestHeaders, + body: requestBody, + signal: effectiveInit.signal ?? undefined, + dispatcher, + // No `maxRedirections`: request() does not auto-follow by default, so the caller's + // `followRedirectsGuarded` drives every hop with per-hop SSRF validation. + }) + + const responseHeaders = new Headers() + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) for (const v of value) responseHeaders.append(key, v) + else if (value != null) responseHeaders.append(key, value) + } + + // Null-body statuses (204/205/304) can't carry a body; drain undici's (empty) stream so its + // socket returns to the pool. Attach an error listener first so a socket reset mid-drain + // surfaces as a handled event, not an unhandled 'error' that crashes the process. + const isNullBody = statusCode === 204 || statusCode === 205 || statusCode === 304 + if (isNullBody) { + body.on('error', () => {}) + body.resume() + const response = new Response(null, { status: statusCode, headers: responseHeaders }) + Object.defineProperty(response, 'url', { value: url, configurable: true }) + return response + } + + // Decode Content-Encoding like `fetch` does (`request()` returns raw bytes). `maxResponseSize` + // still caps the compressed wire bytes on `body`. + const contentEncoding = String(headers['content-encoding'] ?? '') + .toLowerCase() + .trim() + const decoder = contentEncodingDecoder(contentEncoding) + if (decoder) { + // The bridged body is now decoded; drop framing headers that would misdescribe it. + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + } + // Build the bridge over the stream the consumer reads (the decoder when decoding). + // `nodeReadableToWebStream` attaches its `error` listener synchronously, so wiring the pipe + // AFTER it means a synchronous zlib error (e.g. a server mislabeling a non-gzip body as gzip) + // is caught and rejects the reader instead of taking down the process. + const webBody = nodeReadableToWebStream(decoder ?? body) + if (decoder) { + body.once('error', (err) => decoder.destroy(err)) // forward maxResponseSize / socket reset + decoder.once('close', () => body.destroy()) // tear the source down so the socket can't leak + body.pipe(decoder) + } + + try { + const response = new Response(webBody, { status: statusCode, headers: responseHeaders }) + // undici.request never sets `url`; `fetch` did, and consumers rely on it (the MCP + // transport's response-cap wrapper copies it; the SDK resolves relative + // auth-metadata URLs against it). Preserve parity. + Object.defineProperty(response, 'url', { value: url, configurable: true }) + return response + } catch (err) { + // `new Response` rejects an out-of-range status (a 1xx undici shouldn't surface, but + // defensively): destroy the source so its socket can't leak, then rethrow. + body.destroy() + throw err + } +} + /** * SSRF-guarded `fetch` + its `Agent` for outbound requests to user-controlled * hosts: DNS resolves normally, and every socket connect validates the chosen @@ -637,11 +898,9 @@ export function createSsrfGuardedFetchWithDispatcher(options?: { maxResponseSize ...(options?.maxResponseSize !== undefined ? { maxResponseSize: options.maxResponseSize } : {}), }) - const rawFetch = async (url: string, init: UndiciRequestInit): Promise => { - const response = await undiciFetch(url, { ...init, dispatcher }) - // double-cast-allowed: undici Response and DOM Response are structurally compatible at runtime - return response as unknown as Response - } + const rawFetch = (url: string, init: UndiciRequestInit): Promise => + // double-cast-allowed: DOM RequestInit and undici RequestInit differ in TS but match at runtime + undiciRequestAsResponse(url, init as unknown as RequestInit, dispatcher) const guarded = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts new file mode 100644 index 00000000000..be6cc7ad757 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getTokenServiceAccountDescriptor, + getTokenServiceAccountErrorMessage, + HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, + SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, + type TokenServiceAccountDescriptor, +} from '@/lib/credentials/token-service-accounts/descriptors' + +function descriptorFor(providerId: string): TokenServiceAccountDescriptor { + const descriptor = getTokenServiceAccountDescriptor(providerId) + if (!descriptor) throw new Error(`missing descriptor for ${providerId}`) + return descriptor +} + +describe('getTokenServiceAccountErrorMessage', () => { + const shopify = descriptorFor(SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID) + const hubspot = descriptorFor(HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID) + + it('uses the provider-specific invalidCredentialsHelp override when present', () => { + expect(shopify.invalidCredentialsHelp).toBeDefined() + expect(getTokenServiceAccountErrorMessage(shopify, 'invalid_credentials')).toBe( + shopify.invalidCredentialsHelp + ) + }) + + it('falls back to the generic token-noun message when no override is set', () => { + expect(hubspot.invalidCredentialsHelp).toBeUndefined() + expect(getTokenServiceAccountErrorMessage(hubspot, 'invalid_credentials')).toBe( + `We couldn't authenticate with that ${hubspot.tokenNoun}. Double-check it in ${hubspot.serviceLabel} and try again.` + ) + }) + + it('maps site_not_found to the domain hint', () => { + expect(getTokenServiceAccountErrorMessage(shopify, 'site_not_found')).toBe( + "We couldn't find an account at that domain. Check the spelling and try again." + ) + }) + + it('maps provider_unavailable to a service-labeled retry message', () => { + expect(getTokenServiceAccountErrorMessage(hubspot, 'provider_unavailable')).toBe( + `We couldn't reach ${hubspot.serviceLabel} to verify these credentials. Try again in a moment.` + ) + }) + + it('maps duplicate_display_name to the name-collision message', () => { + expect(getTokenServiceAccountErrorMessage(shopify, 'duplicate_display_name')).toBe( + 'A credential with that name already exists in this workspace.' + ) + }) + + it('falls back to a generic message for an unknown or absent code', () => { + const fallback = "We couldn't add this credential. Try again in a moment." + expect(getTokenServiceAccountErrorMessage(shopify, 'something_else')).toBe(fallback) + expect(getTokenServiceAccountErrorMessage(shopify, undefined)).toBe(fallback) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index e60338c3b71..9b606e0fd4f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -6,8 +6,9 @@ * API key, …) instead of running an OAuth flow — mirroring the Atlassian * service-account pattern: the token is verified once server-side, encrypted, * and returned as the access token at execution time with no exchange or - * refresh. This module holds only UI/contract metadata (field lists, labels, - * docs links); server-side verification lives in + * refresh. This module holds the client-safe UI/contract metadata (field + * lists, labels, docs links) plus pure derivations over it (required-field + * lookups, connect-modal error copy); server-side verification lives in * `@/lib/credentials/token-service-accounts/server`. */ @@ -46,6 +47,13 @@ export interface TokenServiceAccountDescriptor { docsUrl: string /** Optional one-line caveat rendered under the token field. */ helpText?: string + /** + * Optional provider-specific message that replaces the generic + * `invalid_credentials` rejection copy. Use it to name the exact + * credential-paste mistake most users make (e.g. copying the API secret key + * instead of the Admin API access token) rather than a vague "double-check". + */ + invalidCredentialsHelp?: string /** * HTTP auth scheme the pasted token requires at execution time. Defaults to * `bearer` (`Authorization: Bearer `); `x-api-token` providers (e.g. @@ -263,6 +271,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', helpText: 'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.', + invalidCredentialsHelp: + 'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.', }, [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -398,3 +408,31 @@ export function getTokenServiceAccountDescriptor( ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[providerId] : undefined } + +/** + * Maps a credential-verification `error.code` to a user-facing message for a + * given provider. Provider-specific copy is inherited from the descriptor + * (token noun, service label, and the optional `invalidCredentialsHelp` + * override) rather than hard-coded in the shared connect modal. An + * unknown/absent code falls back to a generic retry message. + */ +export function getTokenServiceAccountErrorMessage( + descriptor: TokenServiceAccountDescriptor, + code: string | undefined +): string { + switch (code) { + case 'invalid_credentials': + return ( + descriptor.invalidCredentialsHelp ?? + `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` + ) + case 'site_not_found': + return "We couldn't find an account at that domain. Check the spelling and try again." + case 'provider_unavailable': + return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.` + case 'duplicate_display_name': + return 'A credential with that name already exists in this workspace.' + default: + return "We couldn't add this credential. Try again in a moment." + } +} diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index f8784e9bb5d..b53fc10cff3 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' const mockFetch = vi.fn() @@ -48,7 +49,7 @@ describe('validateShopifyServiceAccount', () => { normalizedDomain: 'acme-store.myshopify.com', }) expect(mockFetch).toHaveBeenCalledWith( - 'https://acme-store.myshopify.com/admin/api/2024-10/graphql.json', + `https://acme-store.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, { method: 'POST', headers: { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 4f71977b37a..e0a2f9a605d 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -9,14 +9,7 @@ import type { TokenServiceAccountFields, TokenServiceAccountValidationResult, } from '@/lib/credentials/token-service-accounts/server' - -/** - * Pinned to match the Admin API version hardcoded in every Sim Shopify tool - * (`apps/sim/tools/shopify/*`) — bump both together. Note 2024-10 is retired; - * Shopify silently serves the oldest supported version for retired versions, - * so validation and tool runtime still hit the same effective API. - */ -const SHOPIFY_API_VERSION = '2024-10' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' /** * SSRF guard: the Admin API must target the permanent `*.myshopify.com` diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index bdd197df0a3..540ae694595 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -76,98 +76,236 @@ describe('shouldReconcileDeletions', () => { }) }) -describe('shouldSkipEmptyListing', () => { - it('does not skip when the listing is non-empty', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') +describe('shouldRunIncrementalSync', () => { + const lastSyncAt = '2026-07-01T00:00:00.000Z' - expect(shouldSkipEmptyListing(1, 5, undefined, {})).toBe(false) + it('runs incrementally when everything is eligible', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') + + expect( + shouldRunIncrementalSync(true, 'incremental', undefined, undefined, false, lastSyncAt) + ).toBe(true) }) - it('does not skip when there are no existing documents to lose', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally when the connector does not support it', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 0, undefined, {})).toBe(false) + expect( + shouldRunIncrementalSync(false, 'incremental', undefined, undefined, false, lastSyncAt) + ).toBe(false) }) - it('does not skip on a forced fullSync', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally when the connector is configured for full syncs', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, true, {})).toBe(false) + expect(shouldRunIncrementalSync(true, 'full', undefined, undefined, false, lastSyncAt)).toBe( + false + ) }) - it('skips by default on an empty listing with existing documents', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally on a forced fullSync or rehydrate', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, {})).toBe(true) - expect(shouldSkipEmptyListing(0, 5, undefined, undefined)).toBe(true) - expect(shouldSkipEmptyListing(0, 5, false, {})).toBe(true) + expect(shouldRunIncrementalSync(true, 'incremental', true, undefined, false, lastSyncAt)).toBe( + false + ) + expect(shouldRunIncrementalSync(true, 'incremental', undefined, true, false, lastSyncAt)).toBe( + false + ) }) - it('does not skip when the connector confirms the empty result against the source', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('never runs incrementally before the first sync', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, { sourceConfirmedEmpty: true })).toBe(false) + expect(shouldRunIncrementalSync(true, 'incremental', undefined, undefined, false, null)).toBe( + false + ) }) - it('still skips when sourceConfirmedEmpty is falsy', async () => { - const { shouldSkipEmptyListing } = await import('@/lib/knowledge/connectors/sync-engine') + it('forces a full listing whenever pending-removal documents exist, so they get a resurrect-or-confirm decision', async () => { + const { shouldRunIncrementalSync } = await import('@/lib/knowledge/connectors/sync-engine') - expect(shouldSkipEmptyListing(0, 5, undefined, { sourceConfirmedEmpty: false })).toBe(true) + expect( + shouldRunIncrementalSync(true, 'incremental', undefined, undefined, true, lastSyncAt) + ).toBe(false) }) }) -describe('exceedsDeletionSafetyThreshold', () => { - it('does not block a small deletion, even above 50%', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' +describe('partitionSyncReconciliation', () => { + const live = (id: string, externalId: string | null = id) => ({ id, externalId }) + const noFailures = new Set() + + it('marks a live document missing from the listing as pending removal, not hard-deleted', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([live('a')], [], new Set(), noFailures, undefined) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: ['a'], hardDeleteIds: [] }) + }) + + it('hard-deletes a document already pending removal that is still absent', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([], [live('a')], new Set(), noFailures, undefined) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: ['a'] }) + }) + + it('resurrects a pending-removal document that reappears in the listing', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(4, 5, undefined, {})).toBe(false) + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: [], hardDeleteIds: [] }) }) - it('does not block a large deletion below 50%', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('leaves a document untouched when it is still present in the listing', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a')], + [], + new Set(['a']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(6, 20, undefined, {})).toBe(false) + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) - it('blocks a deletion above both the ratio and count thresholds by default', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('resurrects even on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation([], [live('a')], new Set(['a']), noFailures, true) + + expect(result.resurrectIds).toEqual(['a']) + }) + + it('hard-deletes both live and pending-removal documents immediately on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a')], + [live('b')], + new Set(), + noFailures, + true ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, {})).toBe(true) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, undefined)).toBe(true) + expect(result.softDeleteIds).toEqual([]) + expect(result.hardDeleteIds.sort()).toEqual(['a', 'b']) }) - it('does not block on a forced fullSync', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('handles a mixed batch of every outcome in one pass', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('kept'), live('newly-missing')], + [live('resurrected'), live('confirmed-gone')], + new Set(['kept', 'resurrected']), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(10, 10, true, {})).toBe(false) + expect(result).toEqual({ + resurrectIds: ['resurrected'], + softDeleteIds: ['newly-missing'], + hardDeleteIds: ['confirmed-gone'], + }) }) - it('does not block when the connector confirms the deletion against the source', async () => { - const { exceedsDeletionSafetyThreshold } = await import( - '@/lib/knowledge/connectors/sync-engine' + it('ignores documents with a null externalId', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [live('a', null)], + [live('b', null)], + new Set(), + noFailures, + undefined ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, { sourceConfirmedEmpty: true })).toBe( - false + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) + }) + + it('does not resurrect a reappearing document whose content refresh failed', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + new Set(['a']), + undefined ) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) - it('still blocks when sourceConfirmedEmpty is falsy', async () => { - const { exceedsDeletionSafetyThreshold } = await import( + it('still refuses to resurrect a failed refresh even on a forced fullSync', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('a')], + new Set(['a']), + new Set(['a']), + true + ) + + expect(result.resurrectIds).toEqual([]) + }) + + it('resurrects the ones that succeeded while excluding the one that failed', async () => { + const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine') + + const result = partitionSyncReconciliation( + [], + [live('ok'), live('failed')], + new Set(['ok', 'failed']), + new Set(['failed']), + undefined + ) + + expect(result.resurrectIds).toEqual(['ok']) + }) +}) + +describe('filterStillOwnedReconciliationIds', () => { + it('keeps ids present in the ownership snapshot', async () => { + const { filterStillOwnedReconciliationIds } = await import( '@/lib/knowledge/connectors/sync-engine' ) - expect(exceedsDeletionSafetyThreshold(10, 10, undefined, { sourceConfirmedEmpty: false })).toBe( - true + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set(['a', 'b', 'c'])) + + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: ['b'], hardDeleteIds: ['c'] }) + }) + + it('drops ids a concurrent connector-delete already detached', async () => { + const { filterStillOwnedReconciliationIds } = await import( + '@/lib/knowledge/connectors/sync-engine' ) + + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set(['a'])) + + expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: [], hardDeleteIds: [] }) + }) + + it('returns all-empty lists when nothing is still owned', async () => { + const { filterStillOwnedReconciliationIds } = await import( + '@/lib/knowledge/connectors/sync-engine' + ) + + const result = filterStillOwnedReconciliationIds(['a'], ['b'], ['c'], new Set()) + + expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] }) }) }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 67ecfe374b1..65b0d04542f 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -245,44 +245,107 @@ export function shouldReconcileDeletions( } /** - * Decides whether a zero-document listing should skip deletion reconciliation. + * Decides whether a sync should use the connector's incremental listing. * - * An empty listing is normally indistinguishable from a provider outage, so - * reconciliation is skipped by default rather than risk wiping a knowledge base on - * a bad response. A connector can set `sourceConfirmedEmpty` on `syncContext` to - * vouch that it verified the empty result directly against the source — not - * merely an empty listing page — e.g. a single-resource connector confirming its - * one source item was trashed/removed via a dedicated metadata lookup. + * A pending-removal document only surfaces in an incremental listing if its + * content changed since last sync — an unchanged-but-still-present document + * never appears in an incremental delta at all, so it could never be + * resurrected and would stay tombstoned indefinitely on a connector that runs + * incrementally from here on. `hasTombstonedDocs` forces a full listing + * whenever any pending-removal document exists for this connector, so every + * one of them gets a real resurrect-or-confirm decision on this sync. */ -export function shouldSkipEmptyListing( - externalDocsCount: number, - existingDocsCount: number, +export function shouldRunIncrementalSync( + supportsIncrementalSync: boolean | undefined, + syncMode: string | null | undefined, fullSync: boolean | undefined, - syncContext: Record | undefined + rehydrate: boolean | undefined, + hasTombstonedDocs: boolean, + lastSyncAt: string | Date | null | undefined ): boolean { - if (externalDocsCount !== 0 || existingDocsCount === 0 || fullSync) return false - return !syncContext?.sourceConfirmedEmpty + return Boolean( + supportsIncrementalSync && + syncMode !== 'full' && + !fullSync && + !hasTombstonedDocs && + !rehydrate && + lastSyncAt != null + ) } +/** A stored document's identity, as read back for reconciliation. */ +type ReconciliationDoc = { id: string; externalId: string | null } + /** - * Decides whether a deletion should be blocked by the mass-deletion safety - * threshold (more than half of existing documents, over 5) instead of proceeding. + * Partitions a connector's stored documents against the current listing into + * the three reconciliation actions. * - * This guards against a connector-side bug or transient glitch producing a - * listing that looks mostly empty. `sourceConfirmedEmpty` bypasses it the same - * way it bypasses `shouldSkipEmptyListing` — the connector positively verified - * the deletion against the source rather than merely inferring it from a - * listing, so the extra caution this threshold provides doesn't apply. + * A document absent from a normal (non-fullSync) listing is never purged + * immediately — an empty or shrunken listing can equally mean a transient + * source outage, and a single bad observation must never cause an + * irreversible mass deletion. It is instead marked pending-removal + * (`softDeleteIds`), and only becomes eligible for hard deletion + * (`hardDeleteIds`) once a *later* sync confirms it's still absent — i.e. it + * was already pending-removal (`tombstonedDocs`) coming into this sync. A + * document that reappears while pending-removal is resurrected + * (`resurrectIds`) regardless of `fullSync`, since presence — unlike absence — + * is trustworthy evidence even from a partial listing. A document whose + * content refresh was attempted but failed (`failedExternalIds`) is excluded + * from resurrection even though it was seen — surfacing it now would show + * known-stale pre-tombstone content; it stays tombstoned for a later sync to + * retry. + * + * A forced `fullSync` is an explicit request to reconcile right now: it skips + * the grace period and purges everything absent in one pass. */ -export function exceedsDeletionSafetyThreshold( - removedCount: number, - existingCount: number, - fullSync: boolean | undefined, - syncContext: Record | undefined -): boolean { - if (fullSync || syncContext?.sourceConfirmedEmpty) return false - const deletionRatio = existingCount > 0 ? removedCount / existingCount : 0 - return deletionRatio > 0.5 && removedCount > 5 +export function partitionSyncReconciliation( + existingDocs: ReconciliationDoc[], + tombstonedDocs: ReconciliationDoc[], + seenExternalIds: Set, + failedExternalIds: Set, + fullSync: boolean | undefined +): { resurrectIds: string[]; softDeleteIds: string[]; hardDeleteIds: string[] } { + const resurrectIds = tombstonedDocs + .filter( + (d) => + d.externalId && seenExternalIds.has(d.externalId) && !failedExternalIds.has(d.externalId) + ) + .map((d) => d.id) + const liveMissingIds = existingDocs + .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .map((d) => d.id) + const tombstonedStillMissingIds = tombstonedDocs + .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) + .map((d) => d.id) + + if (fullSync) { + return { + resurrectIds, + softDeleteIds: [], + hardDeleteIds: [...liveMissingIds, ...tombstonedStillMissingIds], + } + } + return { resurrectIds, softDeleteIds: liveMissingIds, hardDeleteIds: tombstonedStillMissingIds } +} + +/** + * Re-filters the three reconciliation ID lists against a fresh ownership + * snapshot taken under the connector's `FOR UPDATE` lock, dropping any + * document a concurrent "delete connector, keep documents" request already + * detached (its `connectorId` no longer matches) since the lists were first + * computed. + */ +export function filterStillOwnedReconciliationIds( + resurrectIds: string[], + softDeleteIds: string[], + hardDeleteIds: string[], + stillOwnedIds: Set +): { resurrectIds: string[]; softDeleteIds: string[]; hardDeleteIds: string[] } { + return { + resurrectIds: resurrectIds.filter((id) => stillOwnedIds.has(id)), + softDeleteIds: softDeleteIds.filter((id) => stillOwnedIds.has(id)), + hardDeleteIds: hardDeleteIds.filter((id) => stillOwnedIds.has(id)), + } } /** @@ -473,18 +536,57 @@ export async function executeSync( let hasMore = true const syncContext: Record = { syncRunId: generateId() } + // Shared cutoff for both the tombstone-retry bound below and the stuck-document + // retry near the end of this sync — same RETRY_WINDOW_DAYS window, one computation. + const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000) + + /** + * Bounded to the same retry window as the stuck-document retry below: a + * document whose refresh keeps failing every sync (e.g. permanently + * oversized) would otherwise be a tombstone that never resolves, forcing a + * full listing — and its listing-time overhead — for this connector + * forever. Past the window, this connector stops forcing full syncs on its + * account; the document itself is unaffected and stays tombstoned either way. + * + * Known accepted trade-off: once past the window, a still-tombstoned + * document that's unchanged-but-genuinely-present at the source can only + * be resurrected by a full listing — and nothing here forces one anymore. + * On a connector that never runs a full sync again (persistent incremental + * syncMode, no manual full resync), that document stays correctly + * invisible (excluded everywhere by `isNull(deletedAt)`, so no + * search/billing/listing leakage) but unresolved indefinitely. This is + * deliberately not "fixed" by hard-deleting it after the window expires — + * that would delete a document we have no positive evidence is actually + * gone, reintroducing the exact risk this whole design exists to avoid. + */ + const hasTombstonedDocs = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNotNull(document.deletedAt), + gt(document.deletedAt, retryCutoff) + ) + ) + .limit(1) + .then((rows) => rows.length > 0) + /** * Determine if this sync should be incremental. A `rehydrate` request forces a * full listing too: re-hydration must see *every* document (a container page can * be unchanged itself yet transclude a page that changed), and an incremental * listing would omit those unchanged containers, so they'd never be re-fetched. */ - const isIncremental = - connectorConfig.supportsIncrementalSync && - connector.syncMode !== 'full' && - !options?.fullSync && - !options?.rehydrate && - connector.lastSyncAt != null + const isIncremental = shouldRunIncrementalSync( + connectorConfig.supportsIncrementalSync, + connector.syncMode, + options?.fullSync, + options?.rehydrate, + hasTombstonedDocs, + connector.lastSyncAt + ) const lastSyncAt = isIncremental && connector.lastSyncAt ? new Date(connector.lastSyncAt) : undefined @@ -548,7 +650,7 @@ export async function executeSync( connectorId, }) - const [existingDocs, excludedDocs] = await Promise.all([ + const [existingDocs, tombstonedDocs, excludedDocs] = await Promise.all([ db .select({ id: document.id, @@ -563,63 +665,66 @@ export async function executeSync( isNull(document.deletedAt) ) ), + // Docs already marked pending-removal by a prior sync's reconciliation (see + // shouldReconcileDeletions below): absent from the source once, not yet + // absent twice in a row. Included in classification so a document that + // reappears is recognized as existing (resurrected) rather than re-added + // as a duplicate. db - .select({ externalId: document.externalId }) + .select({ + id: document.id, + externalId: document.externalId, + contentHash: document.contentHash, + deletedAt: document.deletedAt, + }) .from(document) .where( and( eq(document.connectorId, connectorId), - eq(document.userExcluded, true), isNull(document.archivedAt), - isNull(document.deletedAt) + isNotNull(document.deletedAt) ) ), - ]) - - const excludedExternalIds = new Set(excludedDocs.map((d) => d.externalId).filter(Boolean)) - - if ( - shouldSkipEmptyListing( - externalDocs.length, - existingDocs.length, - options?.fullSync, - syncContext - ) - ) { - logger.warn( - `Source returned 0 documents but ${existingDocs.length} exist — skipping reconciliation`, - { connectorId } - ) - - await completeSyncLog(syncLogId, 'completed', result) - - const now = new Date() - await db - .update(knowledgeConnector) - .set({ - status: 'active', - lastSyncAt: now, - lastSyncError: null, - nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes), - consecutiveFailures: 0, - updatedAt: now, - }) + // Not filtered on deletedAt: a document can be both userExcluded and + // tombstoned (e.g. excluded via a bulk request that raced a sync marking + // it pending-removal). Excluding it here regardless of tombstone state + // keeps it short-circuited in the classification loop below instead of + // silently reappearing through the normal update/resurrect path. + db + .select({ externalId: document.externalId }) + .from(document) .where( and( - eq(knowledgeConnector.id, connectorId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) + eq(document.connectorId, connectorId), + eq(document.userExcluded, true), + isNull(document.archivedAt) ) - ) + ), + ]) - return result - } + const excludedExternalIds = new Set(excludedDocs.map((d) => d.externalId).filter(Boolean)) - const existingByExternalId = new Map( - existingDocs.filter((d) => d.externalId !== null).map((d) => [d.externalId!, d]) + const priorByExternalId = new Map( + [...existingDocs, ...tombstonedDocs] + .filter((d) => d.externalId !== null) + .map((d) => [d.externalId!, d]) ) const seenExternalIds = new Set() + /** + * externalIds whose content was never verified as current: a hydration + * error, a rejected write, a fulfilled-but-unusable hydration (skipped as + * oversized, or an empty re-fetch), a listing-time skippedReason + * short-circuit, or empty non-deferred content (`drop`) — all fall back to + * either keeping the stored content as last-known-good or discarding the + * listing entry outright, without ever comparing or refreshing content. + * That's fine for an already-visible document, but for a tombstoned one it + * means we still don't have confirmed-current content — so this excludes + * them from resurrection below: a tombstoned document whose refresh didn't + * actually land must stay tombstoned rather than come back visible while + * still serving stale pre-tombstone content. + */ + const failedExternalIds = new Set() const pendingOps: DocOp[] = [] for (const extDoc of externalDocs) { @@ -631,7 +736,7 @@ export async function executeSync( continue } - const existing = existingByExternalId.get(extDoc.externalId) + const existing = priorByExternalId.get(extDoc.externalId) const classification = classifyExternalDoc(extDoc, existing, forceRehydrate) switch (classification.type) { @@ -639,6 +744,10 @@ export async function executeSync( pendingOps.push({ type: 'skip', extDoc }) break case 'drop': + // Empty, non-deferred content is never usable. If this was a + // reappearing tombstoned document, its content was never verified as + // current — see failedExternalIds below. + if (existing) failedExternalIds.add(extDoc.externalId) logger.info(`Skipping empty document: ${extDoc.title}`, { externalId: extDoc.externalId, }) @@ -650,6 +759,12 @@ export async function executeSync( pendingOps.push({ type: 'update', existingId: classification.existingId, extDoc }) break case 'unchanged': + // A listing-time skippedReason short-circuits classification before + // the hash comparison, so this is "kept as last-known-good", not a + // verified-unchanged match — same as the deferred-hydration + // equivalent above. A genuine hash match never sets skippedReason, + // so this only fires for the short-circuited case. + if (extDoc.skippedReason && existing) failedExternalIds.add(extDoc.externalId) result.docsUnchanged++ break } @@ -704,15 +819,21 @@ export async function executeSync( }) } else if (op.type === 'update') { // Already-indexed file is kept as last-known-good (not downgraded), so it - // counts as unchanged rather than slipping past every result counter. + // counts as unchanged rather than slipping past every result counter. Not a + // verified refresh, though — see failedExternalIds below. result.docsUnchanged++ + failedExternalIds.add(op.extDoc.externalId) } return null } if (!fullDoc?.content.trim()) { // An empty re-fetch leaves an already-indexed update as last-known-good; count - // it as unchanged so the totals still reconcile with documents seen. - if (op.type === 'update') result.docsUnchanged++ + // it as unchanged so the totals still reconcile with documents seen. Not a + // verified refresh, though — see failedExternalIds below. + if (op.type === 'update') { + result.docsUnchanged++ + failedExternalIds.add(op.extDoc.externalId) + } return null } const hydratedHash = fullDoc.contentHash ?? op.extDoc.contentHash @@ -725,7 +846,7 @@ export async function executeSync( if ( op.type === 'update' && !forceRehydrate && - existingByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash + priorByExternalId.get(op.extDoc.externalId)?.contentHash === hydratedHash ) { result.docsUnchanged++ return null @@ -745,13 +866,16 @@ export async function executeSync( }) ) - for (const outcome of hydrated) { + for (let i = 0; i < hydrated.length; i++) { + const outcome = hydrated[i] if (outcome.status === 'fulfilled' && outcome.value) { readyOps.push(outcome.value) } else if (outcome.status === 'rejected') { result.docsFailed++ + failedExternalIds.add(deferredOps[i].extDoc.externalId) logger.error('Failed to hydrate deferred document', { connectorId, + externalId: deferredOps[i].extDoc.externalId, error: getErrorMessage(outcome.reason), }) } @@ -814,6 +938,7 @@ export async function executeSync( else result.docsUpdated++ } else { result.docsFailed++ + failedExternalIds.add(batch[j].extDoc.externalId) logger.error('Failed to process document', { connectorId, externalId: batch[j].extDoc.externalId, @@ -841,35 +966,108 @@ export async function executeSync( } } - if (shouldReconcileDeletions(isIncremental, syncContext, options?.fullSync)) { - const removedIds = existingDocs - .filter((d) => d.externalId && !seenExternalIds.has(d.externalId)) - .map((d) => d.id) - - if (removedIds.length > 0) { - if ( - exceedsDeletionSafetyThreshold( - removedIds.length, - existingDocs.length, - options?.fullSync, - syncContext - ) - ) { - logger.warn( - `Skipping deletion of ${removedIds.length}/${existingDocs.length} docs — exceeds safety threshold. Trigger a full sync to force cleanup.`, - { - connectorId, - deletionRatio: Math.round((removedIds.length / existingDocs.length) * 100), - } - ) - } else { - await hardDeleteDocuments(removedIds, syncLogId) - result.docsDeleted += removedIds.length + const { resurrectIds, softDeleteIds, hardDeleteIds } = partitionSyncReconciliation( + existingDocs, + tombstonedDocs, + seenExternalIds, + failedExternalIds, + options?.fullSync + ) + + const reconcileDeletionsAllowed = shouldReconcileDeletions( + isIncremental, + syncContext, + options?.fullSync + ) + const gatedSoftDeleteIds = reconcileDeletionsAllowed ? softDeleteIds : [] + const gatedHardDeleteIds = reconcileDeletionsAllowed ? hardDeleteIds : [] + + const candidateIds = [ + ...new Set([...resurrectIds, ...gatedSoftDeleteIds, ...gatedHardDeleteIds]), + ] + + let safeResurrectIds: string[] = [] + let safeSoftDeleteIds: string[] = [] + let safeHardDeleteIds: string[] = [] + + if (candidateIds.length > 0) { + /** + * A concurrent "delete connector, keep documents" request detaches these + * same documents (connectorId set to NULL) under the same FOR UPDATE lock + * the DELETE route takes on this connector row. Taking that lock here + * serializes the two requests: whichever commits first wins, and the + * loser's re-check below sees the up-to-date connectorId and skips any + * document the other request already claimed — instead of resurrecting or + * deleting a document that another request just detached (and possibly + * already billed) as a standalone KB entry. + */ + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` + ) + + const stillOwned = new Set( + ( + await tx + .select({ id: document.id }) + .from(document) + .where(and(inArray(document.id, candidateIds), eq(document.connectorId, connectorId))) + ).map((d) => d.id) + ) + + const stillOwnedResult = filterStillOwnedReconciliationIds( + resurrectIds, + gatedSoftDeleteIds, + gatedHardDeleteIds, + stillOwned + ) + safeResurrectIds = stillOwnedResult.resurrectIds + safeSoftDeleteIds = stillOwnedResult.softDeleteIds + safeHardDeleteIds = stillOwnedResult.hardDeleteIds + + /** + * A document reappearing at the source is trustworthy evidence on its + * own — unlike absence, presence never depends on the listing being + * complete — so resurrection runs unconditionally, even on an + * incremental or otherwise gated sync. + */ + if (safeResurrectIds.length > 0) { + await tx + .update(document) + .set({ deletedAt: null }) + .where(inArray(document.id, safeResurrectIds)) } - } + if (safeSoftDeleteIds.length > 0) { + await tx + .update(document) + .set({ deletedAt: new Date() }) + .where(inArray(document.id, safeSoftDeleteIds)) + } + }) + } + + if (safeResurrectIds.length > 0) { + logger.info( + `Resurrected ${safeResurrectIds.length} documents that reappeared at the source`, + { + connectorId, + } + ) + } + if (safeSoftDeleteIds.length > 0) { + logger.info( + `Marked ${safeSoftDeleteIds.length} documents pending removal — absent from source, confirming on next sync`, + { connectorId } + ) + } + if (safeHardDeleteIds.length > 0) { + // Re-verifies connectorId once more at the moment of the actual delete + // query — the FOR UPDATE lock above only covers the window up to its + // own commit; this closes the remaining gap between that commit and + // this call. + result.docsDeleted += await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId) } - // Check if connector/KB were deleted before retrying stuck documents const postBatchLiveness = await checkSyncLiveness(connectorId, connector.knowledgeBaseId) if (postBatchLiveness.connectorDeleted) { throw new ConnectorDeletedException(connectorId) @@ -885,7 +1083,6 @@ export async function executeSync( // abandoned (e.g. the Trigger.dev task process exited before processing completed). // Documents uploaded more than RETRY_WINDOW_DAYS ago are not retried. const staleProcessingCutoff = new Date(Date.now() - STALE_PROCESSING_MINUTES * 60 * 1000) - const retryCutoff = new Date(Date.now() - RETRY_WINDOW_DAYS * 24 * 60 * 60 * 1000) const stuckDocs = await db .select({ id: document.id, @@ -923,35 +1120,71 @@ export async function executeSync( logger.info(`Retrying ${stuckDocs.length} stuck documents`, { connectorId }) try { const stuckDocIds = stuckDocs.map((doc) => doc.id) + let retryDocs: typeof stuckDocs = [] + + /** + * Takes the same `knowledge_connector` FOR UPDATE lock the DELETE route + * takes before nulling connectorId on detached documents, so the two + * requests serialize instead of racing — a plain re-SELECT only + * narrows the window between the ownership check and these writes, it + * never closes it, since a concurrent detach can still commit in + * between. Embedding cleanup and the processing-state reset happen + * inside the same locked transaction so a document already claimed by + * a detach never gets its embeddings wiped or is reprocessed as if + * still connector-owned. + */ + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE` + ) - await db.delete(embedding).where(inArray(embedding.documentId, stuckDocIds)) + const stillOwnedIds = new Set( + ( + await tx + .select({ id: document.id }) + .from(document) + .where( + and(inArray(document.id, stuckDocIds), eq(document.connectorId, connectorId)) + ) + ).map((d) => d.id) + ) + retryDocs = stuckDocs.filter((doc) => stillOwnedIds.has(doc.id)) + + if (retryDocs.length > 0) { + const retryDocIds = retryDocs.map((doc) => doc.id) + + await tx.delete(embedding).where(inArray(embedding.documentId, retryDocIds)) + + await tx + .update(document) + .set({ + processingStatus: 'pending', + processingStartedAt: null, + processingCompletedAt: null, + processingError: null, + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + }) + .where(inArray(document.id, retryDocIds)) + } + }) - await db - .update(document) - .set({ - processingStatus: 'pending', - processingStartedAt: null, - processingCompletedAt: null, - processingError: null, - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - }) - .where(inArray(document.id, stuckDocIds)) - - await processDocumentsWithQueue( - stuckDocs.map((doc) => ({ - documentId: doc.id, - filename: doc.filename ?? 'document.txt', - fileUrl: doc.fileUrl ?? '', - fileSize: doc.fileSize ?? 0, - mimeType: doc.mimeType ?? 'text/plain', - })), - connector.knowledgeBaseId, - {}, - generateId(), - billingAttribution - ) + if (retryDocs.length > 0) { + await processDocumentsWithQueue( + retryDocs.map((doc) => ({ + documentId: doc.id, + filename: doc.filename ?? 'document.txt', + fileUrl: doc.fileUrl ?? '', + fileSize: doc.fileSize ?? 0, + mimeType: doc.mimeType ?? 'text/plain', + })), + connector.knowledgeBaseId, + {}, + generateId(), + billingAttribution + ) + } } catch (error) { logger.warn('Failed to enqueue stuck documents for reprocessing', { connectorId, @@ -1003,20 +1236,17 @@ export async function executeSync( logger.info('Connector deleted during sync, cleaning up', { connectorId }) try { + // Includes pending-removal (tombstoned) docs — the connector is gone, so + // there's no future sync left to confirm or resurrect them. const connectorDocs = await db .select({ id: document.id }) .from(document) - .where( - and( - eq(document.connectorId, connectorId), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) + .where(and(eq(document.connectorId, connectorId), isNull(document.archivedAt))) await hardDeleteDocuments( connectorDocs.map((doc) => doc.id), - syncLogId + syncLogId, + connectorId ) await completeSyncLog(syncLogId, 'failed', result, 'Connector deleted during sync') @@ -1293,7 +1523,6 @@ async function updateDocument( kbOwner: KnowledgeBaseOwner, sourceConfig?: Record ): Promise { - // Fetch old file URL before uploading replacement const existingRows = await db .select({ fileUrl: document.fileUrl }) .from(document) @@ -1342,12 +1571,21 @@ async function updateDocument( ...tagValues, processingStatus: 'pending', uploadedAt: new Date(), + // A tombstoned document reappearing with changed content is resurrected + // in the same write as its content update — otherwise reconciliation's + // separate resurrect step would clear deletedAt while this update, gated + // on deletedAt IS NULL, rejects the row and leaves stale content active. + deletedAt: null, }) .where( and( eq(document.id, existingDocId), - isNull(document.archivedAt), - isNull(document.deletedAt) + // A concurrent "delete connector, keep documents" request can null out + // connectorId between this sync's liveness check and this write. Without + // this check, that now-standalone document would still match on id alone + // and get overwritten with connector-sourced content post-detachment. + eq(document.connectorId, connectorId), + isNull(document.archivedAt) ) ) .returning({ id: document.id }) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 1df4cd960a0..1b8651a8424 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -2325,7 +2325,17 @@ async function deleteDocumentsByLifecyclePolicy( export async function hardDeleteDocuments( documentIds: string[], - requestId: string + requestId: string, + /** + * When provided, re-verifies each document's connectorId still matches at + * the moment of the actual delete query — not just the caller's earlier + * snapshot. A caller (e.g. connector sync reconciliation) can compute this + * ID list well before the delete runs; a concurrent request that detaches + * these same documents from the connector in between (e.g. "delete + * connector, keep documents") would otherwise still have them purged here + * despite no longer belonging to the connector the caller reasoned about. + */ + expectedConnectorId?: string ): Promise { const ids = [...new Set(documentIds)] if (ids.length === 0) { @@ -2336,7 +2346,8 @@ export async function hardDeleteDocuments( for (let offset = 0; offset < ids.length; offset += HARD_DELETE_DOCUMENT_BATCH_SIZE) { deletedCount += await hardDeleteDocumentBatch( ids.slice(offset, offset + HARD_DELETE_DOCUMENT_BATCH_SIZE), - requestId + requestId, + expectedConnectorId ) } return deletedCount @@ -2346,7 +2357,11 @@ export async function hardDeleteDocuments( * Hard-deletes one bounded metadata batch and applies every associated ledger * delta atomically. */ -async function hardDeleteDocumentBatch(documentIds: string[], requestId: string): Promise { +async function hardDeleteDocumentBatch( + documentIds: string[], + requestId: string, + expectedConnectorId?: string +): Promise { const ids = [...new Set(documentIds)] const documentsToDelete = await db .select({ @@ -2361,7 +2376,11 @@ async function hardDeleteDocumentBatch(documentIds: string[], requestId: string) }) .from(document) .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) - .where(inArray(document.id, ids)) + .where( + expectedConnectorId + ? and(inArray(document.id, ids), eq(document.connectorId, expectedConnectorId)) + : inArray(document.id, ids) + ) if (documentsToDelete.length === 0) { return 0 @@ -2432,10 +2451,31 @@ async function hardDeleteDocumentBatch(documentIds: string[], requestId: string) } } - await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) + /** + * Re-verify `expectedConnectorId` here too, not only on the pre-transaction + * SELECT above — the billing lookups and KB locking between that SELECT + * and this delete are async and can span a concurrent "delete connector, + * keep documents" request that clears these rows' `connectorId` in + * between. Deleting a detached document's embeddings would corrupt its + * search index even if the document row itself were spared, so both the + * embedding delete and the document delete are scoped to this re-verified + * ID set rather than the stale `existingIds`. + */ + const stillTargetedIds = expectedConnectorId + ? ( + await tx + .select({ id: document.id }) + .from(document) + .where( + and(inArray(document.id, existingIds), eq(document.connectorId, expectedConnectorId)) + ) + ).map((d) => d.id) + : existingIds + + await tx.delete(embedding).where(inArray(embedding.documentId, stillTargetedIds)) const deletedRows = await tx .delete(document) - .where(inArray(document.id, existingIds)) + .where(inArray(document.id, stillTargetedIds)) .returning({ id: document.id }) const deletedIds = new Set(deletedRows.map((row) => row.id)) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 99477a53525..1d74cac2b63 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -782,12 +782,9 @@ export const OAUTH_PROVIDERS: Record = { 'groups:write', 'chat:write', 'chat:write.public', - // TODO: Re-add once Slack app review approves these. Requesting a scope - // the app is not yet approved for makes Slack reject the entire - // authorization with "unapproved permissions requested", breaking connect. - // 'assistant:write', - // 'app_mentions:read', - // 'im:history', + 'assistant:write', + 'app_mentions:read', + 'im:history', 'im:write', 'im:read', 'users:read', diff --git a/apps/sim/lib/table/csv-delimiter-stream.ts b/apps/sim/lib/table/csv-delimiter-stream.ts new file mode 100644 index 00000000000..1c4c5c2eb70 --- /dev/null +++ b/apps/sim/lib/table/csv-delimiter-stream.ts @@ -0,0 +1,80 @@ +import { Readable } from 'node:stream' +import { + CSV_DELIMITER_SNIFF_BYTES, + type CsvDelimiter, + detectCsvDelimiter, +} from '@/lib/table/import' + +export interface SniffedCsvStream { + delimiter: CsvDelimiter + /** + * The full file contents, replayed from byte zero. Use this in place of the + * source stream — the source has already been partially consumed. + */ + stream: Readable +} + +/** + * Reads the head of a CSV/TSV stream, sniffs its field separator, then returns a + * stream that replays the buffered head followed by the untouched remainder. + * + * Memory is bounded: it buffers only the source chunks needed to reach the + * {@link CSV_DELIMITER_SNIFF_BYTES} window (one chunk past it, at worst), and the + * detection sample it copies is capped at exactly that window regardless of how + * large a single upstream chunk is. Those buffered chunks are then replayed + * *by reference* — never re-copied — so a multi-GB import stays O(sniff window) + * plus the single in-flight chunk. {@link detectCsvDelimiter} drops any partial + * trailing line, so no newline trimming is needed here. + */ +export async function sniffCsvDelimiterFromStream( + source: Readable, + fallback: CsvDelimiter = ',' +): Promise { + const reader = source[Symbol.asyncIterator]() + const chunks: Buffer[] = [] + let size = 0 + let exhausted = false + + // Read until the buffered size *exceeds* the window (not just reaches it) or the stream ends. + // The `<=` is deliberate: a file whose size is exactly the window must still trigger one more + // read so EOF is observed and `exhausted` becomes true — otherwise it would be misjudged as a + // truncated prefix and disagree with the buffered callers (which pass complete for that size). + while (size <= CSV_DELIMITER_SNIFF_BYTES) { + const next = await reader.next() + if (next.done) { + exhausted = true + break + } + const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value as Uint8Array) + chunks.push(chunk) + size += chunk.length + } + + // Copy at most the sniff window for detection — `Buffer.concat`'s length arg truncates, + // so an oversized final chunk can't inflate this allocation past CSV_DELIMITER_SNIFF_BYTES. + const sample = Buffer.concat(chunks, Math.min(size, CSV_DELIMITER_SNIFF_BYTES)) + // `exhausted` (the loop stopped on end-of-stream, never on exceeding the window) means the whole + // file fit in the window, so its last row must count even without a trailing newline. Otherwise + // the sample is a truncated prefix whose final line may be partial and should be dropped. + const delimiter = await detectCsvDelimiter(sample, fallback, { complete: exhausted }) + + const stream = Readable.from( + (async function* replay() { + // Replay the already-read chunks by reference (no re-copy), then drain the rest. + for (const chunk of chunks) yield chunk + if (exhausted) return + while (true) { + const next = await reader.next() + if (next.done) return + yield next.value + } + })() + ) + + // `Readable.from` closes the generator on destroy, which returns the source + // iterator — but an early destroy of the wrapper before the generator is + // pulled would otherwise leak the source's socket. + stream.on('close', () => source.destroy()) + + return { delimiter, stream } +} diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 64694f97bf3..85f85875577 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -19,6 +19,7 @@ import { } from '@/lib/table' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { withGeneratedColumnIds } from '@/lib/table/column-keys' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { appendTableEvent } from '@/lib/table/events' import { addImportColumns, @@ -103,6 +104,11 @@ export async function runTableImport(payload: TableImportPayload): Promise // Stream the file rather than buffering it — a ~1M-row import must never be held in memory. source = await downloadFileStream({ key: fileKey, context: 'workspace' }) + // The kickoff route's extension-derived delimiter is only the fallback — the separator is + // sniffed from the file's head so semicolon/pipe exports don't collapse into one column. + const sniffed = await sniffCsvDelimiterFromStream(source, delimiter) + const csvStream = sniffed.stream + // Append must continue after the existing rows; create/replace start empty. Read once up // front (the import is the table's sole writer) and assign contiguous positions / threaded // order keys from it. @@ -123,11 +129,14 @@ export async function runTableImport(payload: TableImportPayload): Promise }, }) - const parser = createCsvParser(delimiter) + let csvHeaders: string[] = [] + const parser = createCsvParser(sniffed.delimiter, (headers) => { + csvHeaders = headers + }) // `.pipe` doesn't forward source errors; forward so the iterator throws. - source.on('error', (err) => parser.destroy(err)) + csvStream.on('error', (err) => parser.destroy(err)) byteCounter.on('error', (err) => parser.destroy(err)) - source.pipe(byteCounter).pipe(parser) + csvStream.pipe(byteCounter).pipe(parser) let schema: TableSchema | null = null let headerToColumn: Map | null = null @@ -142,7 +151,7 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { - const headers = Object.keys(sample[0]) + const headers = csvHeaders if (mode === 'create') { const inferred = inferSchemaFromCsv(headers, sample) diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index d4997a123fe..91baff2b94e 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -2,14 +2,19 @@ * @vitest-environment node */ import { Readable } from 'node:stream' +import { sleep } from '@sim/utils/helpers' import { describe, expect, it } from 'vitest' +import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream' import { buildAutoMapping, + CSV_DELIMITER_SNIFF_BYTES, CsvImportValidationError, coerceRowsForTable, coerceValue, createCsvParser, csvParseOptions, + dedupeHeaders, + detectCsvDelimiter, inferColumnType, inferSchemaFromCsv, parseCsvBuffer, @@ -316,8 +321,225 @@ describe('import', () => { }) describe('csvParseOptions', () => { - it('sets columns, bom, and the delimiter', () => { - expect(csvParseOptions('\t')).toMatchObject({ columns: true, bom: true, delimiter: '\t' }) + it('sets bom and the delimiter, with a header-capturing columns callback', () => { + const options = csvParseOptions('\t') + expect(options).toMatchObject({ bom: true, delimiter: '\t' }) + expect(typeof options.columns).toBe('function') + }) + + it('invokes onHeaders with the full header row', () => { + let captured: string[] | null = null + const options = csvParseOptions(',', (h) => { + captured = h + }) + // csv-parse calls the columns function with the parsed header array. + ;(options.columns as (h: string[]) => string[])(['a', 'b', 'c']) + expect(captured).toEqual(['a', 'b', 'c']) + }) + }) + + describe('parseCsvBuffer header derivation', () => { + it('reports every header even when the first data row is ragged (short)', async () => { + // With relax_column_count a short first row omits trailing keys, so deriving + // headers from Object.keys(rows[0]) would drop c and d. The header callback fixes this. + const { headers } = await parseCsvBuffer('a,b,c,d\n1,2\n3,4,5,6\n') + expect(headers).toEqual(['a', 'b', 'c', 'd']) + }) + + it('feeds the full header set into inferSchemaFromCsv for a ragged file', async () => { + const { headers, rows } = await parseCsvBuffer('a,b,c\n1\n2,3,4\n') + const { columns } = inferSchemaFromCsv(headers, rows) + expect(columns.map((c) => c.name)).toEqual(['a', 'b', 'c']) + }) + + it('collapses duplicate header names to match the parser record keys', async () => { + // csv-parse keys duplicate columns onto one object key (last value wins); the reported + // headers must match, so schema inference does not invent a phantom empty column. + const { headers, rows } = await parseCsvBuffer('a,a,b\n1,2,3\n4,5,6\n') + expect(headers).toEqual(['a', 'b']) + expect(Object.keys(rows[0])).toEqual(['a', 'b']) + const { columns } = inferSchemaFromCsv(headers, rows) + expect(columns.map((c) => c.name)).toEqual(['a', 'b']) + }) + }) + + describe('dedupeHeaders', () => { + it('drops later exact duplicates, preserving first-occurrence order', () => { + expect(dedupeHeaders(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']) + }) + + it('keeps case-distinct names (matches csv-parse key sensitivity)', () => { + expect(dedupeHeaders(['Name', 'name'])).toEqual(['Name', 'name']) + }) + }) + + describe('detectCsvDelimiter', () => { + it('detects a comma-delimited file', async () => { + expect(await detectCsvDelimiter('a,b,c\n1,2,3\n')).toBe(',') + }) + + it('detects a semicolon-delimited file (European Excel export)', async () => { + expect(await detectCsvDelimiter('a;b;c\n1;2;3\n')).toBe(';') + }) + + it('detects tab and pipe delimiters', async () => { + expect(await detectCsvDelimiter('a\tb\tc\n1\t2\t3\n')).toBe('\t') + expect(await detectCsvDelimiter('a|b|c\n1|2|3\n')).toBe('|') + }) + + it('ignores delimiters that appear only inside quoted fields', async () => { + // Semicolon-separated, but the values are full of commas and newlines — a raw + // character-frequency count would wrongly pick the comma. A real parse does not. + const csv = 'id;body\n1;"hello, world\nsecond line"\n2;"a, b, c"\n' + expect(await detectCsvDelimiter(csv)).toBe(';') + }) + + it('falls back for a single-column file rather than latching onto in-value characters', async () => { + expect(await detectCsvDelimiter('text\n"hello, world"\n"a, b"\n')).toBe(',') + expect(await detectCsvDelimiter('text\n"hello, world"\n', ';')).toBe(';') + }) + + it('prefers the consistent delimiter over one that only widens the header row', async () => { + // The header splits into more fields on comma, but only semicolon yields a uniform + // column count across the data rows — consistency must win over raw field count. + expect(await detectCsvDelimiter('a,b;c,d;e,f\n1;2;3\n4;5;6\n')).toBe(';') + }) + + it('detects the real delimiter when the header itself has quoted commas', async () => { + const csv = '"last, first";age;city\n"Doe, John";30;NYC\n"Roe, Jane";25;LA\n' + expect(await detectCsvDelimiter(csv)).toBe(';') + }) + + it('prefers a wider ragged split over a narrow one that only looks uniform', async () => { + // Semicolon is the real delimiter (2- and 3-column rows) while a pipe appears once per + // row inside values. Scoring width*consistency keeps the wider semicolon split. + expect(await detectCsvDelimiter('a|label;b;c\nx|y;1\nz|w;2;3\n')).toBe(';') + }) + + it('falls back to comma order only when the split is genuinely ambiguous', async () => { + // Both ; and , yield exactly two uniform columns — no content signal distinguishes them, + // so the global-default candidate order (comma first) decides and either reading is valid. + expect(await detectCsvDelimiter('name;value,unit\na;1,kg\nb;2,kg\n')).toBe(',') + }) + + it('drops a partial trailing line so a mid-record byte cut cannot skew detection', async () => { + // Simulates a fixed byte-window slice that ends mid-record; the last (partial) line + // is ignored, so the complete rows above still drive the result. + expect(await detectCsvDelimiter('a;b;c\n1;2;3\n4;5;')).toBe(';') + }) + + it('keeps the final row of a complete file that has no trailing newline', async () => { + // Without complete:true the trim would drop the only distinguishing data row, leaving the + // header alone and letting comma (which merely widens the header) win over semicolon. + const csv = 'a,b;c,d;e,f\n1;2;3' + expect(await detectCsvDelimiter(csv, ',', { complete: true })).toBe(';') + // A truncated prefix (complete:false, the default) still drops the partial tail. + expect(await detectCsvDelimiter(csv)).toBe(',') + }) + + it('returns the fallback for empty input', async () => { + expect(await detectCsvDelimiter('', ';')).toBe(';') + }) + }) + + describe('sniffCsvDelimiterFromStream', () => { + async function collect(stream: Readable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)) + } + return Buffer.concat(chunks) + } + + it('detects the delimiter and replays the full file byte-for-byte', async () => { + const rows = ['id;a;b;c'] + for (let i = 0; i < 5000; i++) rows.push(`${i};"val, with comma";x;y`) + const full = Buffer.from(`${rows.join('\n')}\n`) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('handles a file smaller than the sniff window (exhausted during sniff)', async () => { + const full = Buffer.from('a;b;c\n1;2;3\n') + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('treats a file exactly the sniff-window size as complete (observes EOF at the boundary)', async () => { + // Header widens under comma; a single giant data row pads the file to exactly the window + // with no trailing newline. If the loop stopped at the boundary without seeing EOF, the row + // would be dropped and comma would win — so this passing proves the boundary EOF is observed. + const header = 'a,b;c,d;e,f\n' + const suffix = ';3' + const pad = 'x'.repeat( + CSV_DELIMITER_SNIFF_BYTES - header.length - '1;'.length - suffix.length + ) + const full = Buffer.from(`${header}1;${pad}${suffix}`) + expect(full.length).toBe(CSV_DELIMITER_SNIFF_BYTES) + const { delimiter } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + }) + + it('counts the final row of a fully-buffered file with no trailing newline', async () => { + // The whole file fits the sniff window (exhausted), so complete:true flows through and the + // last row is scored — semicolon must win despite comma widening the header row. + const full = Buffer.from('a,b;c,d;e,f\n1;2;3') + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('detects and replays exactly when a single chunk far exceeds the sniff window', async () => { + // One >1 MiB chunk arrives before the size check — detection must still cap its copy + // and the replay must emit the original bytes unchanged. + const rows = ['a;b;c'] + for (let i = 0; i < 40000; i++) rows.push(`${i};${'x'.repeat(20)};y`) + const full = Buffer.from(`${rows.join('\n')}\n`) + expect(full.length).toBeGreaterThan(1024 * 1024) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full])) + expect(delimiter).toBe(';') + expect((await collect(stream)).equals(full)).toBe(true) + }) + + it('reassembles data split across many small chunks', async () => { + const parts = ['na', 'me,ag', 'e\nfo', 'o,1\nba', 'r,2\n'].map((s) => Buffer.from(s)) + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from(parts)) + expect(delimiter).toBe(',') + expect((await collect(stream)).equals(Buffer.concat(parts))).toBe(true) + }) + + it('destroys the source when the replay stream is destroyed early', async () => { + let destroyed = false + const pad = 'z'.repeat(290) + async function* infinite() { + let i = 0 + while (true) yield Buffer.from(`r${i++};x;${pad}\n`) + } + const source = Readable.from(infinite()) + source.on('close', () => { + destroyed = true + }) + const { stream } = await sniffCsvDelimiterFromStream(source) + stream.destroy() + await sleep(20) + expect(destroyed).toBe(true) + }) + + it('surfaces a source error that occurs during replay', async () => { + async function* failing() { + yield Buffer.from(`a;b;c\n${'1;2;3\n'.repeat(20000)}`) + throw new Error('storage read failed') + } + const { stream } = await sniffCsvDelimiterFromStream(Readable.from(failing())) + await expect(collect(stream)).rejects.toThrow(/storage read failed/) + }) + + it('returns the fallback for an empty stream', async () => { + const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([]), ';') + expect(delimiter).toBe(';') + expect((await collect(stream)).length).toBe(0) }) }) }) diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 104186e32e1..94b4dc2272d 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -16,14 +16,44 @@ import { getColumnId } from '@/lib/table/column-keys' import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' +/** + * Field separators we sniff for, in tie-break priority order. Semicolon files are + * the standard CSV export of European-locale Excel; pipe shows up in log exports. + */ +export const CSV_DELIMITER_CANDIDATES = [',', ';', '\t', '|'] as const + +export type CsvDelimiter = (typeof CSV_DELIMITER_CANDIDATES)[number] + +/** + * Bytes inspected when sniffing the delimiter. Read from the head of the file on + * every path (client preview, streamed upload, background worker) so all of them + * observe the same prefix and therefore agree on the result. + */ +export const CSV_DELIMITER_SNIFF_BYTES = 64 * 1024 + /** * Single source of truth for the `csv-parse` options used by both the buffered - * sync parser and the streaming parser. `columns: true` emits each record as an - * object keyed by the (first-row) headers. + * sync parser and the streaming parser. + * + * `columns` is a function rather than `true` so the *actual header row* is + * captured. With `relax_column_count`, a record shorter than the header simply + * omits the trailing keys, so `Object.keys(records[0])` under-reports the schema + * whenever the first data row is ragged — the header callback is authoritative. */ -export function csvParseOptions(delimiter = ','): CsvParseOptions { +export function csvParseOptions( + delimiter = ',', + onHeaders?: (headers: string[]) => void +): CsvParseOptions { return { - columns: true, + columns: (header: string[]) => { + // Deliver headers deduped to match the record keys: csv-parse collapses duplicate + // column names into a single object key (last value wins), so the consumer's schema + // inference and mapping must see the same unique set — not the raw duplicates, which + // would invent phantom columns that never receive a value. csv-parse still keys on the + // raw array we return here, so returning it unchanged preserves that collapsing. + onHeaders?.(dedupeHeaders(header)) + return header + }, skip_empty_lines: true, trim: true, relax_column_count: true, @@ -40,9 +70,127 @@ export function csvParseOptions(delimiter = ','): CsvParseOptions { * file stream into it and iterate records with `for await`; backpressure flows * back to the source while each record is processed. Use this for HTTP uploads * so the file is never fully buffered in memory. + * + * `onHeaders` fires once, before the first record, with the full header row. + */ +export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser { + return parseCsvStream(csvParseOptions(delimiter, onHeaders)) +} + +/** + * Drops later exact-duplicate header names, preserving first-occurrence order. Mirrors how + * `csv-parse` collapses duplicate column names into a single record key (last value wins), so + * the header set stays in lockstep with the object keys the parser actually emits. + */ +export function dedupeHeaders(headers: string[]): string[] { + const seen = new Set() + const unique: string[] = [] + for (const header of headers) { + if (seen.has(header)) continue + seen.add(header) + unique.push(header) + } + return unique +} + +/** Decodes CSV bytes as UTF-8, passing strings through unchanged. */ +export function decodeCsvText(input: Buffer | Uint8Array | string): string { + if (typeof input === 'string') return input + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) return input.toString('utf-8') + return new TextDecoder('utf-8').decode(input as Uint8Array) +} + +/** + * Sniffs the field separator by trial-parsing the sample with each candidate and + * keeping the one whose column count is most *consistent* across rows. + * + * A real parse (rather than counting raw characters) is what makes this safe on + * files whose quoted cells contain other candidates — `alarms.csv` exports a + * semicolon-separated file whose `raw_text` cells are full of commas and + * newlines, and a naive frequency count picks the comma. + * + * Each candidate is scored `modalWidth × consistency` — the modal (most common) + * row width times the fraction of rows at that width. This balances the two + * failure modes: ranking on width alone lets a delimiter that merely widens the + * header win (a comma splitting a semicolon file's unquoted header), while + * ranking on consistency alone lets a separator that appears uniformly *inside* + * values win over a real delimiter whose rows are legitimately ragged (a pipe + * embedded once per row beating a semicolon that yields 2- and 3-column rows). + * The product rewards a split that is both wide and uniform; ties break toward + * the wider split, then toward candidate order. Using the modal width (not the + * first row's) keeps one stray ragged row from distorting the score, and a + * single-column file (no candidate reaches two columns) falls back to `fallback`. + * + * Files that stay exactly tied on both score and width are genuinely ambiguous — + * e.g. `name;value,unit` splits into two columns under either `;` or `,` — so the + * global-default candidate order (comma first) decides, and both readings still + * produce a valid table. + * + * All callers funnel through here, so the sample is prepared identically for + * every import path: when the sample is a prefix sliced out of a larger file, a + * possibly-partial trailing line is dropped before parsing so a mid-record cut + * can't skew the counts. Pass `complete: true` when the sample is the entire file + * (nothing was sliced off) so a final row with no trailing newline still counts — + * dropping it would silently discard the only distinguishing data row of a tiny + * file and let a header-widening separator win. */ -export function createCsvParser(delimiter = ','): Parser { - return parseCsvStream(csvParseOptions(delimiter)) +export async function detectCsvDelimiter( + input: Buffer | Uint8Array | string, + fallback: CsvDelimiter = ',', + { complete = false }: { complete?: boolean } = {} +): Promise { + const { parse } = await import('csv-parse/sync') + const decoded = decodeCsvText(input) + // Drop a partial final line only when the sample is a truncated prefix; keep every row when + // the sample is the whole file (or a single line) so a trailing-newline-less last row counts. + const lastNewline = decoded.lastIndexOf('\n') + const text = !complete && lastNewline > 0 ? decoded.slice(0, lastNewline + 1) : decoded + if (text.trim() === '') return fallback + + let best: { delimiter: CsvDelimiter; fields: number; score: number } | null = null + + for (const delimiter of CSV_DELIMITER_CANDIDATES) { + let records: string[][] + try { + records = parse(text, { + columns: false, + skip_empty_lines: true, + relax_column_count: true, + relax_quotes: true, + skip_records_with_error: true, + bom: true, + delimiter, + }) as string[][] + } catch { + continue + } + + if (records.length === 0) continue + + // Modal row width: the most frequent column count, tie-broken toward the wider one. + const widthCounts = new Map() + for (const record of records) { + widthCounts.set(record.length, (widthCounts.get(record.length) ?? 0) + 1) + } + let fields = 0 + let modalFreq = 0 + for (const [width, freq] of widthCounts) { + if (freq > modalFreq || (freq === modalFreq && width > fields)) { + fields = width + modalFreq = freq + } + } + if (fields < 2) continue + + // Reward a split that is both wide and uniform; ties break toward the wider split. + const score = fields * (modalFreq / records.length) + + if (!best || score > best.score || (score === best.score && fields > best.fields)) { + best = { delimiter, fields, score } + } + } + + return best?.delimiter ?? fallback } /** Narrower type than `COLUMN_TYPES` used internally for coercion. */ @@ -103,24 +251,20 @@ export async function parseCsvBuffer( ): Promise<{ headers: string[]; rows: Record[] }> { const { parse } = await import('csv-parse/sync') - let text: string - if (typeof input === 'string') { - text = input - } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { - text = input.toString('utf-8') - } else { - text = new TextDecoder('utf-8').decode(input as Uint8Array) - } + const text = decodeCsvText(input) - // double-cast-allowed: shared csvParseOptions() loses the `columns: true` literal that drives - // csv-parse's record-vs-string[][] overload, but `columns: true` is always set so records are objects - const parsed = parse(text, csvParseOptions(delimiter)) as unknown as Record[] + let headers: string[] = [] + const options = csvParseOptions(delimiter, (h) => { + headers = h + }) + // double-cast-allowed: shared csvParseOptions() loses the `columns` literal that drives + // csv-parse's record-vs-string[][] overload, but `columns` is always set so records are objects + const parsed = parse(text, options) as unknown as Record[] if (parsed.length === 0) { throw new Error('CSV file has no data rows') } - const headers = Object.keys(parsed[0]) if (headers.length === 0) { throw new Error('CSV file has no headers') } @@ -504,7 +648,11 @@ export async function parseFileRows( return parseJsonRows(buffer) } if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') { - const delimiter = ext === 'tsv' ? '\t' : ',' + const delimiter = await detectCsvDelimiter( + buffer.subarray(0, CSV_DELIMITER_SNIFF_BYTES), + ext === 'tsv' ? '\t' : ',', + { complete: buffer.length <= CSV_DELIMITER_SNIFF_BYTES } + ) return parseCsvBuffer(buffer, delimiter) } throw new Error(`Unsupported file format: "${ext ?? fileName}". Supported: csv, tsv, json`) diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 2ffbf360fe7..da0f6f4a4f0 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -1,10 +1,10 @@ /** * @vitest-environment node */ -import { credential } from '@sim/db/schema' -import { resetDbChainMock } from '@sim/testing' +import { account, credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' import { eq } from 'drizzle-orm' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -25,7 +25,33 @@ vi.mock('@/lib/webhooks/pending-verification', () => ({ PendingWebhookVerificationTracker: vi.fn(), })) -import { buildProviderConfig, resolveTriggerCredentialId } from '@/lib/webhooks/deploy' +const { + mockGetSlackBotCredential, + mockResolveOAuthAccountId, + mockRefreshAccessTokenIfNeeded, + mockFetchSlackTeamId, +} = vi.hoisted(() => ({ + mockGetSlackBotCredential: vi.fn(), + mockResolveOAuthAccountId: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), + mockFetchSlackTeamId: vi.fn(), +})) +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getSlackBotCredential: mockGetSlackBotCredential, + resolveOAuthAccountId: mockResolveOAuthAccountId, + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) +vi.mock('@/lib/webhooks/providers/slack', () => ({ + fetchSlackTeamId: mockFetchSlackTeamId, +})) + +import { + buildProviderConfig, + resolveTriggerCredentialId, + resolveWebhookConfigForBlock, +} from '@/lib/webhooks/deploy' +import { getBlock } from '@/blocks' +import { getTrigger } from '@/triggers' afterAll(resetDbChainMock) @@ -191,3 +217,145 @@ describe('resolveTriggerCredentialId', () => { expect(eq).toHaveBeenCalledWith(credential.accountId, 'credential-1') }) }) + +describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { + const slackTriggerDef = { + provider: 'slack_app', + name: 'Slack', + subBlocks: [ + { id: 'eventType', mode: 'trigger', required: true }, + { + id: 'customBotCredential', + mode: 'trigger', + canonicalParamId: 'botCredential', + serviceId: 'slack', + required: true, + }, + { + id: 'manualBotCredential', + mode: 'trigger-advanced', + canonicalParamId: 'botCredential', + required: true, + }, + ], + } + + function resolveSlack( + values: Record, + workflow: Record = { workspaceId: 'ws-1' } + ) { + ;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' }) + ;(getTrigger as unknown as Mock).mockReturnValue(slackTriggerDef) + return resolveWebhookConfigForBlock({ + block: makeBlock('slack_oauth', values), + workflow, + userId: 'deployer-1', + requestId: 'req-1', + }) + } + + it('routes a custom bot credential by credential id on the slack provider', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'ws-1', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack') + expect(result.config.routingKey).toBe('cred_bot_1') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('BUSER') + }) + + it('rejects a custom bot credential from another workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue({ workspaceId: 'other-ws', botUserId: 'BUSER' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + }) + + it('rejects a deleted or secretless custom bot credential as an invalid bot', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_bot_x' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('bot credential is missing or invalid') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects an OAuth credential not resolvable in the workflow workspace', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + // No credential row queued → resolveTriggerCredentialId returns null. + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_foreign' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available in this workspace') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('rejects a non-simSubscribed event on the native Sim app (OAuth account)', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + + const result = await resolveSlack({ + eventType: 'file_shared', + customBotCredential: 'cred_oauth_1', + }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('not available on the Sim Slack app') + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + }) + + it('routes an OAuth account by team_id on the slack_app provider', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'acct-1' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + queueTableRows(account, [{ userId: 'owner-1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('xoxb-token') + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T123', userId: 'UBOT' }) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(true) + if (!result?.success) throw new Error('expected success') + expect(result.config.provider).toBe('slack_app') + expect(result.config.routingKey).toBe('T123') + expect(result.config.triggerPath).toBeNull() + expect(result.config.providerConfig.bot_user_id).toBe('UBOT') + // Runtime token resolution + disconnect cleanup key slack_app rows on this. + expect(result.config.providerConfig.credentialId).toBe('cred_oauth_1') + // Owner's token, not the deploying actor's. + expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith('cred_oauth_1', 'owner-1', 'req-1') + }) + + it('fails when the connected Slack account token cannot be resolved', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + mockResolveOAuthAccountId.mockResolvedValue({ accountId: '' }) + queueTableRows(credential, [{ id: 'cred_oauth_1' }]) + mockRefreshAccessTokenIfNeeded.mockResolvedValue(null) + + const result = await resolveSlack({ eventType: 'message', customBotCredential: 'cred_oauth_1' }) + + expect(result?.success).toBe(false) + if (result?.success) throw new Error('expected failure') + expect(result?.error?.status).toBe(400) + expect(result?.error?.message).toContain('Could not access the connected Slack account') + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1678159e2f7..7bb32655690 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' +import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -15,6 +15,7 @@ import { projectDesiredWebhookProviderConfig, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' +import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' import { prepareStableWebhookRegistrations, type StableDesiredWebhookRegistration, @@ -26,12 +27,17 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { getSlackBotCredential } from '@/app/api/auth/oauth/utils' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' +import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared' const logger = createLogger('DeployWebhookSync') @@ -355,7 +361,12 @@ export async function resolveTriggerCredentialId( return resolvedCredential?.id ?? null } -async function resolveWebhookConfigForBlock(input: { +/** + * Resolves a trigger block to its persisted webhook config, including the + * Slack-specific routing branch. Exported for unit testing that branch; not part + * of the public deploy API. + */ +export async function resolveWebhookConfigForBlock(input: { block: BlockState workflow: Record userId: string @@ -425,43 +436,135 @@ async function resolveWebhookConfigForBlock(input: { let effectivePath: string | null = triggerPath let routingKey: string | null = null if (triggerId === 'slack_oauth') { - // Custom-bot only: events route by the bot credential to one shared ingest - // URL verified with the bot's own signing secret. The native Sim-app path - // (team_id routing via the official app) is intentionally not supported yet. - const botCredentialId = + // One credential picker feeds two backends. The credential's resolved kind — + // not a UI field — picks the branch: a Slack bot credential routes by the + // credential id (custom bring-your-own app); an OAuth account routes by Slack + // team_id on the native shared Sim app. + const slackCredentialId = typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined - if (!botCredentialId) { + if (!slackCredentialId) { return { success: false, - error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, + error: { message: 'Select a Slack account or bot for the trigger.', status: 400 }, } } - const botCredential = await getSlackBotCredential(botCredentialId) - if (!botCredential) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', - status: 400, - }, + const botCredential = await getSlackBotCredential(slackCredentialId) + if (botCredential) { + // Custom bring-your-own bot: events route by the bot credential to one + // shared ingest URL verified with the bot's own signing secret. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is not available in this workspace.', + status: 400, + }, + } } - } - const workflowWorkspace = - typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined - if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { - return { - success: false, - error: { - message: 'The selected Slack bot credential is not available in this workspace.', - status: 400, - }, + effectiveProvider = 'slack' + effectivePath = null + routingKey = slackCredentialId + providerConfig.credentialId = slackCredentialId + if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId + } else { + // getSlackBotCredential also returns null for a custom bot credential that + // was deleted or lost its stored secrets. Name that case so the error + // directs the user to reconnect the bot rather than mislabeling it an OAuth + // account below. + const resolvedKind = await resolveOAuthAccountId(slackCredentialId) + if (resolvedKind?.credentialType === 'service_account') { + return { + success: false, + error: { + message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', + status: 400, + }, + } + } + // Native Sim app: a workspace OAuth Slack credential. Resolve it through the + // same workspace/provider-scoped lookup the generic credential path uses, so + // a pasted foreign or other-tenant credential id can't bind here and the + // canonical id is what routing and runtime token resolution key on. + const workflowWorkspace = + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined + const resolvedCredentialId = workflowWorkspace + ? await resolveTriggerCredentialId(slackCredentialId, workflowWorkspace, 'slack') + : null + if (!resolvedCredentialId) { + return { + success: false, + error: { + message: 'The selected Slack credential is not available in this workspace.', + status: 400, + }, + } + } + // The shared app only subscribes to a fixed event set; reject anything + // outside it before deriving routing. + const eventType = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + if (!eventType || !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { + return { + success: false, + error: { + message: + 'This event is not available on the Sim Slack app. Use a custom bot or choose a supported event.', + status: 400, + }, + } + } + // Resolve the credential OWNER's token (not the deploying actor's) — in a + // shared workspace a teammate can deploy a trigger wired to someone else's + // Slack account. + let tokenOwnerUserId = input.userId + const resolvedAccount = await resolveOAuthAccountId(resolvedCredentialId) + if (resolvedAccount?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolvedAccount.accountId)) + .limit(1) + if (owner?.userId) tokenOwnerUserId = owner.userId + } + const botToken = await refreshAccessTokenIfNeeded( + resolvedCredentialId, + tokenOwnerUserId, + input.requestId + ) + if (!botToken) { + return { + success: false, + error: { + message: 'Could not access the connected Slack account. Reconnect it and try again.', + status: 400, + }, + } + } + try { + const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) + routingKey = teamId + if (botUserId) providerConfig.bot_user_id = botUserId + } catch (error: unknown) { + logger.error( + `[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`, + error + ) + return { + success: false, + error: { + message: 'Could not verify the connected Slack workspace. Reconnect it and try again.', + status: 400, + }, + } } + effectiveProvider = 'slack_app' + effectivePath = null + // Runtime token resolution and credential-disconnect cleanup key native + // (`slack_app`) rows on providerConfig.credentialId. + providerConfig.credentialId = resolvedCredentialId } - effectiveProvider = 'slack' - effectivePath = null - routingKey = botCredentialId - providerConfig.credentialId = botCredentialId - if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId } return { diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 50fb1b1938d..4f55dd0bbb7 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -1,11 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' -import { - type StartBlockCandidate, - StartBlockPath, - TRIGGER_TYPES, -} from '@/lib/workflows/triggers/triggers' +import { type StartBlockCandidate, StartBlockPath } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' @@ -239,10 +235,6 @@ export function getTriggerDisplayName(blockType: string): string { const block = getBlock(blockType) if (!block) return blockType - if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) { - return 'Webhook' - } - return block.name } diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts index a7b5077afcb..6fe61714756 100644 --- a/apps/sim/lib/workflows/triggers/triggers.ts +++ b/apps/sim/lib/workflows/triggers/triggers.ts @@ -356,9 +356,6 @@ export class TriggerUtils { // Use the block's actual name from the registry const block = getBlock(triggerType) if (block) { - if (triggerType === TRIGGER_TYPES.GENERIC_WEBHOOK) { - return 'Webhook' - } return block.name } diff --git a/apps/sim/package.json b/apps/sim/package.json index 9b2b9b1413b..1b9c251ff56 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -178,7 +178,7 @@ "mongodb": "6.19.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.6", + "next": "16.2.11", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -264,8 +264,8 @@ "sharp" ], "overrides": { - "next": "16.2.6", - "@next/env": "16.2.6", + "next": "16.2.11", + "@next/env": "16.2.11", "mermaid": "11.15.0", "react-floater": { "react": "$react", diff --git a/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg b/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg new file mode 100644 index 00000000000..14234aef1a6 Binary files /dev/null and b/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg differ diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index efce810b781..7084b591044 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -145,7 +145,7 @@ export const useSearchModalStore = create()( const allTriggers = getTriggersForSidebar() const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = [...filteredTriggers].sort( (a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => { diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index 950ca816452..78ce7bc0cc5 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyAdjustInventoryParams, ShopifyInventoryAdjustmentResponse, @@ -48,7 +49,7 @@ export const shopifyAdjustInventoryTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index 3a7ee6d7af9..30d586422b9 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCancelOrderParams, ShopifyCancelOrderResponse } from '@/tools/shopify/types' import { CANCEL_ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -64,7 +65,7 @@ export const shopifyCancelOrderTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/constants.ts b/apps/sim/tools/shopify/constants.ts new file mode 100644 index 00000000000..92fb5cb5867 --- /dev/null +++ b/apps/sim/tools/shopify/constants.ts @@ -0,0 +1,14 @@ +/** + * The Shopify Admin API version every Sim Shopify tool targets, plus the + * credential validator in + * `@/lib/credentials/token-service-accounts/validators/shopify`. Keep this the + * single source of truth — bump it here and every caller moves in lockstep. + * + * Shopify supports each stable version for at least 12 months and forward-falls + * to the oldest supported version for retired ones, so a request never breaks + * the day a version retires — but the served version drifts silently, so pin an + * explicitly supported version and bump on the quarterly cadence. + * + * @see https://shopify.dev/docs/api/usage/versioning + */ +export const SHOPIFY_API_VERSION = '2025-10' diff --git a/apps/sim/tools/shopify/create_customer.ts b/apps/sim/tools/shopify/create_customer.ts index e56d1f2c48d..56460768ec4 100644 --- a/apps/sim/tools/shopify/create_customer.ts +++ b/apps/sim/tools/shopify/create_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateCustomerParams, ShopifyCustomerResponse } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyCreateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_fulfillment.ts b/apps/sim/tools/shopify/create_fulfillment.ts index 05435306d17..76ca156c5dd 100644 --- a/apps/sim/tools/shopify/create_fulfillment.ts +++ b/apps/sim/tools/shopify/create_fulfillment.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateFulfillmentParams, ShopifyFulfillmentResponse, @@ -61,7 +62,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index dd1fb16c8d3..3060658be9c 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCreateProductParams, ShopifyProductResponse } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -63,7 +64,7 @@ export const shopifyCreateProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_customer.ts b/apps/sim/tools/shopify/delete_customer.ts index 010cf109c83..7b3611872fb 100644 --- a/apps/sim/tools/shopify/delete_customer.ts +++ b/apps/sim/tools/shopify/delete_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyDeleteCustomerParams, ShopifyDeleteResponse } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -32,7 +33,7 @@ export const shopifyDeleteCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/delete_product.ts b/apps/sim/tools/shopify/delete_product.ts index 42b3aa843fa..40a7e2dd300 100644 --- a/apps/sim/tools/shopify/delete_product.ts +++ b/apps/sim/tools/shopify/delete_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyDeleteProductParams, ShopifyDeleteResponse } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -32,7 +33,7 @@ export const shopifyDeleteProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index 6cf7afa8dd6..d137b12c08a 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCollectionResponse, ShopifyGetCollectionParams } from '@/tools/shopify/types' import { COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyGetCollectionTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_customer.ts b/apps/sim/tools/shopify/get_customer.ts index e7657df42c7..9763831091d 100644 --- a/apps/sim/tools/shopify/get_customer.ts +++ b/apps/sim/tools/shopify/get_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomerResponse, ShopifyGetCustomerParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -31,7 +32,7 @@ export const shopifyGetCustomerTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index 215bd731a36..394796f8e72 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetInventoryLevelParams, ShopifyInventoryResponse, @@ -42,7 +43,7 @@ export const shopifyGetInventoryLevelTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_order.ts b/apps/sim/tools/shopify/get_order.ts index 65f69ddaa02..be9fbadf279 100644 --- a/apps/sim/tools/shopify/get_order.ts +++ b/apps/sim/tools/shopify/get_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetOrderParams, ShopifyOrderResponse } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const shopifyGetOrderTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/get_product.ts b/apps/sim/tools/shopify/get_product.ts index e99c3b1140f..e024ff6d877 100644 --- a/apps/sim/tools/shopify/get_product.ts +++ b/apps/sim/tools/shopify/get_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyGetProductParams, ShopifyProductResponse } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const shopifyGetProductTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index bc7bd3e2f0e..af8a8c26fbf 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCollectionsResponse, ShopifyListCollectionsParams, @@ -44,7 +45,7 @@ export const shopifyListCollectionsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_customers.ts b/apps/sim/tools/shopify/list_customers.ts index 0ecc1ab31e5..ed68a448359 100644 --- a/apps/sim/tools/shopify/list_customers.ts +++ b/apps/sim/tools/shopify/list_customers.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomersResponse, ShopifyListCustomersParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListCustomersTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 57de928cd96..9f74f969f80 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import { INVENTORY_ITEM_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES, @@ -44,7 +45,7 @@ export const shopifyListInventoryItemsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index f51817b23a3..0aba9f82e3a 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListLocationsParams, ShopifyLocationsResponse } from '@/tools/shopify/types' import { LOCATION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListLocationsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_orders.ts b/apps/sim/tools/shopify/list_orders.ts index 4d818bde393..0d69519c6d6 100644 --- a/apps/sim/tools/shopify/list_orders.ts +++ b/apps/sim/tools/shopify/list_orders.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListOrdersParams, ShopifyOrdersResponse } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -43,7 +44,7 @@ export const shopifyListOrdersTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/list_products.ts b/apps/sim/tools/shopify/list_products.ts index ceb6cae7d23..e8c15fca72c 100644 --- a/apps/sim/tools/shopify/list_products.ts +++ b/apps/sim/tools/shopify/list_products.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyListProductsParams, ShopifyProductsResponse } from '@/tools/shopify/types' import { PAGE_INFO_OUTPUT_PROPERTIES, PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -40,7 +41,7 @@ export const shopifyListProductsTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts index d010c0b0cb9..f735931218c 100644 --- a/apps/sim/tools/shopify/types.ts +++ b/apps/sim/tools/shopify/types.ts @@ -847,12 +847,6 @@ export interface ShopifyAdjustInventoryParams extends ShopifyBaseParams { delta: number } -interface ShopifySetInventoryParams extends ShopifyBaseParams { - inventoryItemId: string - locationId: string - quantity: number -} - // Fulfillment Tool Params export interface ShopifyCreateFulfillmentParams extends ShopifyBaseParams { fulfillmentOrderId: string diff --git a/apps/sim/tools/shopify/update_customer.ts b/apps/sim/tools/shopify/update_customer.ts index af3ee478cbd..4a85667ad90 100644 --- a/apps/sim/tools/shopify/update_customer.ts +++ b/apps/sim/tools/shopify/update_customer.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyCustomerResponse, ShopifyUpdateCustomerParams } from '@/tools/shopify/types' import { CUSTOMER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyUpdateCustomerTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_order.ts b/apps/sim/tools/shopify/update_order.ts index 9e34c3476aa..d03934a16a4 100644 --- a/apps/sim/tools/shopify/update_order.ts +++ b/apps/sim/tools/shopify/update_order.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyOrderResponse, ShopifyUpdateOrderParams } from '@/tools/shopify/types' import { ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -48,7 +49,7 @@ export const shopifyUpdateOrderTool: ToolConfig - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index 771dca61343..9ae243b4757 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -1,3 +1,4 @@ +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' import type { ShopifyProductResponse, ShopifyUpdateProductParams } from '@/tools/shopify/types' import { PRODUCT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' @@ -69,7 +70,7 @@ export const shopifyUpdateProductTool: ToolConfig< request: { url: (params) => - `https://${params.domain || params.shopDomain || params.idToken}/admin/api/2024-10/graphql.json`, + `https://${params.domain || params.shopDomain || params.idToken}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, method: 'POST', headers: (params) => { if (!params.accessToken) { diff --git a/apps/sim/triggers/generic/webhook.ts b/apps/sim/triggers/generic/webhook.ts index 9fc953817af..c546c4d4f5c 100644 --- a/apps/sim/triggers/generic/webhook.ts +++ b/apps/sim/triggers/generic/webhook.ts @@ -4,7 +4,7 @@ import type { TriggerConfig } from '@/triggers/types' export const genericWebhookTrigger: TriggerConfig = { id: 'generic_webhook', - name: 'Generic Webhook', + name: 'Webhook Trigger', provider: 'generic', description: 'Receive webhooks from any service or API', version: '1.0.0', diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 368b4128dc5..fb277ef8594 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -23,11 +23,20 @@ const BOT_FILTER_EVENTS = ['message', 'app_mention'] const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reaction_removed'] /** - * Slack trigger backed by a reusable custom-bot credential (set up once, reused - * across triggers and actions). Events route by that credential - * (`webhook.routingKey = credentialId`) to one shared ingest URL, so many - * triggers on the same bot share a single Request URL, verified with the bot's - * own signing secret. + * Unified Slack trigger. A single credential picker lists both the native Sim + * Slack app (an OAuth-connected account) and reusable custom bots; the deploy + * path resolves the credential's kind server-side to pick the backend: + * - Custom bot: events route by that credential (`webhook.routingKey = + * credentialId`) to one shared ingest URL verified with the bot's own signing + * secret, so many triggers on the same bot share a single Request URL. + * - Native Sim app: events route by Slack `team_id` on the official shared app + * (derived at deploy time via `auth.test`, no path or app setup). Only the + * events the shared app subscribes to are usable; the deploy path enforces + * that (`SIM_SUBSCRIBED_EVENTS`), so the event picker offers every event + * rather than mutating its option set with the selected credential. + * + * The trigger is only reachable through the preview-gated `slack_v2` block, so + * the native Sim-app mode inherits that gate — no separate env flag. */ export const slackOAuthTrigger: TriggerConfig = { id: 'slack_oauth', @@ -51,16 +60,21 @@ export const slackOAuthTrigger: TriggerConfig = { }, { id: 'customBotCredential', - title: 'Slack Bot', + title: 'Slack Account', type: 'oauth-input', canonicalParamId: 'botCredential', serviceId: 'slack', - credentialKind: 'service-account', - credentialLabels: { serviceAccountConnect: 'Set up a custom bot' }, + credentialKind: 'any', + credentialLabels: { + oauthGroup: 'Sim app', + oauthConnect: 'Connect the Sim app', + serviceAccountGroup: 'Custom bots', + serviceAccountConnect: 'Set up a custom bot', + }, requiredScopes: getScopesForService('slack'), - placeholder: 'Select a connected bot', + placeholder: 'Select Slack account or bot', description: - 'Choose a custom Slack bot you set up once and reuse across triggers and actions.', + 'Connect the native Sim Slack app, or choose a custom Slack bot you set up once and reuse across triggers and actions.', required: true, mode: 'trigger', }, diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index 4372f7ba68b..eda88da4c08 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -271,7 +271,16 @@ export const slackEventById = new Map( SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry]) ) -/** Dropdown options for the event picker — every event (manifest generated on demand). */ +/** + * Event ids the official shared Sim app subscribes to. Single source of truth + * for the deploy-time gate that rejects a native Sim-app trigger configured with + * an event the shared app can't deliver. + */ +export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => entry.id) + +/** Dropdown options for the event picker — every selectable event. */ export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({ label: entry.label, id: entry.id, diff --git a/bun.lock b/bun.lock index 9d76c5e4a52..dcce09861c5 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ "fumadocs-openapi": "10.8.1", "fumadocs-ui": "16.8.5", "lucide-react": "^0.511.0", - "next": "16.2.6", + "next": "16.2.11", "next-themes": "^0.4.6", "postgres": "^3.4.5", "react": "19.2.4", @@ -246,7 +246,7 @@ "mongodb": "6.19.0", "mysql2": "3.14.3", "neo4j-driver": "6.0.1", - "next": "16.2.6", + "next": "16.2.11", "next-mdx-remote": "^6.0.0", "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", @@ -408,7 +408,7 @@ "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.479.0", - "next": "16.2.6", + "next": "16.2.11", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -593,11 +593,11 @@ "sharp", ], "overrides": { - "@next/env": "16.2.6", + "@next/env": "16.2.11", "drizzle-orm": "^0.45.2", "mermaid": "11.15.0", "minimatch": "^10.2.5", - "next": "16.2.6", + "next": "16.2.11", "postgres": "^3.4.5", "react": "19.2.4", "react-dom": "19.2.4", @@ -1215,23 +1215,7 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], - - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], - - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -3309,7 +3293,7 @@ "neo4j-driver-core": ["neo4j-driver-core@6.0.1", "", {}, "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="], "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], diff --git a/bunfig.toml b/bunfig.toml index 6145009f007..dbb4f93a7fa 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,18 +5,20 @@ exact = true minimumReleaseAge = 604800 # @typescript/native-preview stays excluded permanently: it only publishes nightly # dev builds, so every version is structurally younger than any age gate. -# typescript@7.0.2 and @typescript/typescript6@6.0.2 were vetted in #5521; they age -# out of the gate on 2026-07-15 and 2026-07-13 — drop those two entries after that. # The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK # migration; they age out of the gate on 2026-07-24 — drop these four entries then. +# next@16.2.11 and @next/env@16.2.11 are the official Vercel security patch for the +# July 2026 advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x +# et al.); published 2026-07-21, they age out of the gate on 2026-07-28 — drop these two +# entries then. minimumReleaseAgeExcludes = [ - "typescript", - "@typescript/typescript6", "@typescript/native-preview", "@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", + "next", + "@next/env", ] [run] diff --git a/package.json b/package.json index f14e0a506ea..ea7fafc1fd4 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "overrides": { "react": "19.2.4", "react-dom": "19.2.4", - "next": "16.2.6", - "@next/env": "16.2.6", + "next": "16.2.11", + "@next/env": "16.2.11", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", diff --git a/packages/emcn/package.json b/packages/emcn/package.json index 7aaa3d4c467..c0a48f0da71 100644 --- a/packages/emcn/package.json +++ b/packages/emcn/package.json @@ -57,7 +57,7 @@ "framer-motion": "^12.5.0", "input-otp": "^1.4.2", "lucide-react": "^0.479.0", - "next": "16.2.6", + "next": "16.2.11", "prismjs": "^1.30.0", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 2ee141b10b2..82ce02c9d05 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -212,7 +212,7 @@ export function WorkflowBlockView({ >