From 97f4e04be9b14b77b243fa8ad678c1fd0dadf633 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 17 Sep 2026 14:39:21 -0700 Subject: [PATCH 1/8] fix(embeddings): preserve safe provider failure diagnostics (#7946) * fix(embeddings): preserve safe provider failure diagnostics * fix(embeddings): classify aggregated batch failures --- .../background/knowledge-processing.test.ts | 3 +- apps/sim/lib/embeddings/api-error.ts | 37 ++++++ apps/sim/lib/embeddings/client.test.ts | 109 +++++++++++++++++- apps/sim/lib/embeddings/client.ts | 37 +++--- .../lib/embeddings/error-diagnostics.test.ts | 75 ++++++++++++ apps/sim/lib/embeddings/error-diagnostics.ts | 84 ++++++++++++++ .../connectors/connector-error.test.ts | 51 ++++++++ .../knowledge/connectors/connector-error.ts | 16 ++- .../document-processing-source.test.ts | 3 +- .../processing-provider-deferral.test.ts | 3 +- 10 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 apps/sim/lib/embeddings/api-error.ts create mode 100644 apps/sim/lib/embeddings/error-diagnostics.test.ts create mode 100644 apps/sim/lib/embeddings/error-diagnostics.ts diff --git a/apps/sim/background/knowledge-processing.test.ts b/apps/sim/background/knowledge-processing.test.ts index 2623409ec68..b200f9a0dbd 100644 --- a/apps/sim/background/knowledge-processing.test.ts +++ b/apps/sim/background/knowledge-processing.test.ts @@ -34,7 +34,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ })) import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error' -import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' +import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' import { EMBEDDING_QUOTA_CIRCUIT_TTL_MS } from '@/lib/embeddings/quota-circuit' import { OcrRequestRejectedError, diff --git a/apps/sim/lib/embeddings/api-error.ts b/apps/sim/lib/embeddings/api-error.ts new file mode 100644 index 00000000000..ff447db3b32 --- /dev/null +++ b/apps/sim/lib/embeddings/api-error.ts @@ -0,0 +1,37 @@ +export class EmbeddingAPIError extends Error { + public status: number + + /** True when the rejected request used a customer-managed credential. */ + public readonly isBYOK: boolean + + /** Rejected for an exhausted balance rather than a recoverable rate limit. */ + public quotaExhausted?: boolean + + /** + * Wait the provider asked for, read from the rejected response. Consumed by + * {@link retryWithExponentialBackoff}, which prefers it over its own backoff. + */ + public retryAfterMs?: number + + constructor(message: string, status: number, isBYOK = false) { + super(message) + this.name = 'EmbeddingAPIError' + this.status = status + this.isBYOK = isBYOK + } +} + +/** Finds an embedding failure through bounded aggregate/cause wrappers. */ +export function getEmbeddingAPIError(error: unknown): EmbeddingAPIError | null { + const pending = [error] + const seen = new Set() + while (pending.length > 0 && seen.size < 32) { + const current = pending.pop() + if (!(current instanceof Error) || seen.has(current)) continue + seen.add(current) + if (current instanceof EmbeddingAPIError) return current + if (current.cause !== undefined) pending.push(current.cause) + if (current instanceof AggregateError) pending.push(...current.errors.slice(0, 32)) + } + return null +} diff --git a/apps/sim/lib/embeddings/client.test.ts b/apps/sim/lib/embeddings/client.test.ts index 5efc3104276..2e807434593 100644 --- a/apps/sim/lib/embeddings/client.test.ts +++ b/apps/sim/lib/embeddings/client.test.ts @@ -2,16 +2,16 @@ * @vitest-environment node */ -import { resetEnvMock, setEnv } from '@sim/testing' +import { createMockLogger, resetEnvMock, setEnv } from '@sim/testing' import { interruptibleSleep } from '@sim/utils/helpers' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' import { assertKnowledgeEmbeddingCapacityForDeployment, clampEmbeddingConcurrency, EMBEDDING_MAX_RETRIES, EMBEDDING_RETRY_BUDGET_MS, - EmbeddingAPIError, EmbeddingOutputLimitError, EmbeddingQuotaExhaustedError, embed, @@ -28,6 +28,11 @@ const { mockGetBYOKKey } = vi.hoisted(() => ({ mockGetBYOKKey: vi.fn(), })) +const { mockDiagnosticWarn } = vi.hoisted(() => ({ mockDiagnosticWarn: vi.fn() })) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ ...createMockLogger(), warn: mockDiagnosticWarn }), +})) + const { quotaGates, mockAdmit, mockCooldown, mockQuotaCheck } = vi.hoisted(() => ({ quotaGates: new Set(), mockAdmit: vi.fn(), @@ -116,6 +121,7 @@ function oversizedChunkedSuccessResponse(): Response { let fetchMock: ReturnType beforeEach(() => { + mockDiagnosticWarn.mockClear() mockQuotaCheck .mockReset() .mockImplementation(async (identity: { credentialFingerprint: string }) => @@ -154,6 +160,105 @@ afterEach(() => { resetEnvMock() }) +describe('embedding HTTP failure diagnostics', () => { + const options = { model: 'text-embedding-3-small', projectInputs: null } as const + + it('logs safe OpenAI context internally while preserving the public error and retry policy', async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { error: { code: 'model_not_found', message: 'private document and private-key' } }, + 404, + { 'x-request-id': 'req_test', authorization: 'Bearer private-key' } + ) + ) + const error = await embed(['private document'], { ...options, apiKey: 'private-key' }).catch( + (caught) => caught + ) + expect(error).toBeInstanceOf(EmbeddingAPIError) + expect(error.message).toBe('Embedding API failed: 404') + expect(isTransientEmbeddingError(error)).toBe(false) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(mockDiagnosticWarn).toHaveBeenCalledWith('Embedding provider request failed', { + providerId: 'openai', + modelName: 'text-embedding-3-small', + status: 404, + providerRequestId: 'req_test', + providerErrorCode: 'model_not_found', + providerErrorType: null, + bodyFormat: 'json', + }) + expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private') + expect(JSON.stringify(error)).not.toContain('req_test') + expect(JSON.stringify(error)).not.toContain('model_not_found') + }) + + it('identifies the actual Azure transport and deployment selected for a catalog model', async () => { + setEnv({ + AZURE_OPENAI_API_KEY: 'private-azure-key', + AZURE_OPENAI_ENDPOINT: 'https://azure.example', + AZURE_OPENAI_API_VERSION: '2024-02-01', + KB_OPENAI_MODEL_NAME: 'test-embedding-deployment', + }) + fetchMock.mockResolvedValue( + jsonResponse({ error: { code: 'DeploymentNotFound' } }, 404, { + 'apim-request-id': 'azure-request-test', + }) + ) + await expect(embed(['text'], options)).rejects.toThrow('Embedding API failed: 404') + expect(mockDiagnosticWarn).toHaveBeenCalledWith( + 'Embedding provider request failed', + expect.objectContaining({ + providerId: 'azure-openai', + modelName: 'test-embedding-deployment', + providerRequestId: 'azure-request-test', + providerErrorCode: 'DeploymentNotFound', + }) + ) + expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private-azure-key') + expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('https://azure.example') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('keeps the HTTP failure diagnosable when its response body cannot be read', async () => { + fetchMock.mockImplementation( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('private body failure')) + }, + }), + { status: 404, headers: { 'x-request-id': 'req_unreadable' } } + ) + ) + await expect(embed(['text'], { ...options, apiKey: 'key' })).rejects.toThrow( + 'Embedding API failed: 404' + ) + expect(mockDiagnosticWarn).toHaveBeenCalledWith( + 'Embedding provider request failed', + expect.objectContaining({ + status: 404, + providerRequestId: 'req_unreadable', + bodyFormat: 'unavailable', + }) + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.stringify(mockDiagnosticWarn.mock.calls)).not.toContain('private') + }) + + it('logs quota rejection before the existing quota circuit wraps the HTTP error', async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: { code: 'insufficient_quota' } }, 429)) + await expect(embed(['text'], { ...options, apiKey: 'key' })).rejects.toBeInstanceOf( + EmbeddingQuotaExhaustedError + ) + expect(mockDiagnosticWarn).toHaveBeenCalledWith( + 'Embedding provider request failed', + expect.objectContaining({ status: 429, providerErrorCode: 'insufficient_quota' }) + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + describe('embedding cancellation', () => { it('cancels a stalled response body after headers arrive without retrying', async () => { vi.useFakeTimers() diff --git a/apps/sim/lib/embeddings/client.ts b/apps/sim/lib/embeddings/client.ts index aea273979a7..c13e25cb5db 100644 --- a/apps/sim/lib/embeddings/client.ts +++ b/apps/sim/lib/embeddings/client.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { chunkArray } from '@sim/utils/helpers' +import { truncate } from '@sim/utils/string' import { getBYOKKey } from '@/lib/api-key/byok' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env, envNumber } from '@/lib/core/config/env' @@ -23,6 +24,7 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' import { getOllamaUrl } from '@/lib/core/utils/urls' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' import { DEFAULT_EMBEDDING_MODEL, type EmbeddingModelInfo, @@ -31,6 +33,7 @@ import { ollamaEmbeddingModelName, resolveDimensions, } from '@/lib/embeddings/catalog' +import { getEmbeddingResponseDiagnostic } from '@/lib/embeddings/error-diagnostics' import { resolveProviderKey } from '@/lib/embeddings/keys' import { isOllamaServerConfigured } from '@/lib/embeddings/ollama-model-catalog.server' import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models' @@ -168,29 +171,6 @@ export const EMBEDDING_RETRY_BUDGET_MS = EMBEDDING_MAX_RETRIES * EMBEDDING_MAX_R */ export const KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS = 60_000 -export class EmbeddingAPIError extends Error { - public status: number - - /** True when the rejected request used a customer-managed credential. */ - public readonly isBYOK: boolean - - /** Rejected for an exhausted balance rather than a recoverable rate limit. */ - public quotaExhausted?: boolean - - /** - * Wait the provider asked for, read from the rejected response. Consumed by - * {@link retryWithExponentialBackoff}, which prefers it over its own backoff. - */ - public retryAfterMs?: number - - constructor(message: string, status: number, isBYOK = false) { - super(message) - this.name = 'EmbeddingAPIError' - this.status = status - this.isBYOK = isBYOK - } -} - class EmbeddingResponseValidationError extends EmbeddingAPIError { constructor(message: string) { super(`Embedding API returned an invalid success response: ${message}`, 502) @@ -293,7 +273,7 @@ function isQuotaExhaustionBody(errorText: string): boolean { } } -/** Reads a bounded provider body only for internal quota classification. */ +/** Reads a bounded provider body for internal diagnostics and quota classification. */ async function readEmbeddingErrorBody(response: Response, signal?: AbortSignal): Promise { try { return await readResponseTextWithLimit(response, { @@ -542,6 +522,7 @@ async function callEmbeddingAPI( tokenizerProvider: string, taskType: EmbeddingTaskType, providerId: EmbeddingProviderKind, + modelName: string, quotaCircuitIdentity: EmbeddingQuotaCircuitIdentity, /** * The caller's explicit reduction, or undefined when none was requested. Kept @@ -605,6 +586,12 @@ async function callEmbeddingAPI( if (!response.ok) { const classificationBody = await readEmbeddingErrorBody(response, controller.signal) + logger.warn('Embedding provider request failed', { + providerId, + modelName: truncate(modelName, 256), + status: response.status, + ...getEmbeddingResponseDiagnostic(response.headers, classificationBody), + }) const error = new EmbeddingAPIError( `Embedding API failed: ${response.status}`, response.status, @@ -856,6 +843,7 @@ async function callCheckpointedEmbeddingBatch( provider.info.tokenizerProvider, taskType, provider.providerId, + provider.modelName, provider.quotaCircuitIdentity, requestedDimensions, provider.dimensions, @@ -1081,6 +1069,7 @@ export async function embedOpenRouter( limits.tokenizerProvider, 'document', 'openrouter', + model, quotaCircuitIdentity, options.dimensions, expectedDimensions, diff --git a/apps/sim/lib/embeddings/error-diagnostics.test.ts b/apps/sim/lib/embeddings/error-diagnostics.test.ts new file mode 100644 index 00000000000..c733cd1d2b6 --- /dev/null +++ b/apps/sim/lib/embeddings/error-diagnostics.test.ts @@ -0,0 +1,75 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { getEmbeddingResponseDiagnostic } from '@/lib/embeddings/error-diagnostics' + +describe('embedding response diagnostics', () => { + it.each([ + ['model_not_found', 'invalid_request_error'], + ['DeploymentNotFound', undefined], + [404, 'NOT_FOUND'], + ])('retains safe machine codes for %s without free-form provider text', (code, type) => { + const diagnostic = getEmbeddingResponseDiagnostic( + new Headers({ 'x-request-id': 'req_test', authorization: 'Bearer private-key' }), + JSON.stringify({ + error: { code, type, message: 'private document', param: 'private input' }, + }) + ) + expect(diagnostic).toEqual({ + providerRequestId: 'req_test', + bodyFormat: 'json', + providerErrorCode: String(code), + providerErrorType: type ?? null, + }) + expect(JSON.stringify(diagnostic)).not.toContain('private') + }) + + it('reads Gemini status independently of its numeric error code', () => { + expect( + getEmbeddingResponseDiagnostic( + new Headers(), + JSON.stringify({ error: { code: 404, status: 'NOT_FOUND' } }) + ) + ).toMatchObject({ providerErrorCode: '404', providerErrorType: 'NOT_FOUND' }) + }) + + it.each(['apim-request-id', 'x-ms-request-id'])('reads the Azure %s header', (header) => { + expect( + getEmbeddingResponseDiagnostic(new Headers({ [header]: 'request-test' }), '') + ).toMatchObject({ providerRequestId: 'request-test', bodyFormat: 'unavailable' }) + }) + + it.each(['', 'private gateway error', '{"error":', 'null', '[]', '"private"'])( + 'tolerates an unavailable, non-JSON, or unexpected body: %s', + (body) => { + const diagnostic = getEmbeddingResponseDiagnostic(new Headers(), body) + expect(diagnostic.providerErrorCode).toBeNull() + expect(diagnostic.providerErrorType).toBeNull() + expect(JSON.stringify(diagnostic)).not.toContain('private') + } + ) + + it.each([ + 'private-key', + 'private document text', + { private: true }, + ['private'], + 'x'.repeat(1000), + ])('does not copy unknown error fields into logs: %j', (value) => { + expect( + getEmbeddingResponseDiagnostic( + new Headers(), + JSON.stringify({ error: { code: value, type: value } }) + ) + ).toMatchObject({ providerErrorCode: 'unrecognized', providerErrorType: 'unrecognized' }) + }) + + it.each(['Bearer private-key', 'https://private.example', 'x'.repeat(129)])( + 'omits malformed or oversized request IDs: %s', + (requestId) => { + expect( + getEmbeddingResponseDiagnostic(new Headers({ 'x-request-id': requestId }), '') + .providerRequestId + ).toBeNull() + } + ) +}) diff --git a/apps/sim/lib/embeddings/error-diagnostics.ts b/apps/sim/lib/embeddings/error-diagnostics.ts new file mode 100644 index 00000000000..7d0d6422335 --- /dev/null +++ b/apps/sim/lib/embeddings/error-diagnostics.ts @@ -0,0 +1,84 @@ +/** Only recognized machine codes may leave a provider's untrusted response body. */ +const SAFE_ERROR_CODES = new Set([ + 'model_not_found', + 'invalid_api_key', + 'invalid_request_error', + 'authentication_error', + 'permission_error', + 'rate_limit_error', + 'rate_limit_exceeded', + 'insufficient_quota', + 'context_length_exceeded', + 'server_error', + 'DeploymentNotFound', + 'ResourceNotFound', + 'OperationNotSupported', + 'InvalidRequest', + 'Unauthorized', + 'Forbidden', + 'TooManyRequests', + 'InternalServerError', + 'ServiceUnavailable', + 'INVALID_ARGUMENT', + 'NOT_FOUND', + 'PERMISSION_DENIED', + 'UNAUTHENTICATED', + 'RESOURCE_EXHAUSTED', + 'INTERNAL', + 'UNAVAILABLE', +]) + +function safeErrorCode(value: unknown): string | null { + if (value === undefined || value === null) return null + if (typeof value === 'string' && SAFE_ERROR_CODES.has(value)) return value + if (typeof value === 'number' && Number.isInteger(value) && value >= 400 && value <= 599) { + return String(value) + } + return 'unrecognized' +} + +function safeRequestId(value: string | null): string | null { + return value && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : null +} + +interface EmbeddingResponseDiagnostic { + providerRequestId: string | null + bodyFormat: 'json' | 'non_json' | 'unavailable' + providerErrorCode: string | null + providerErrorType: string | null +} + +/** + * Internal log metadata only. Never retain free-form messages, request inputs, + * response bodies, or the full header bag on errors that can reach callers. + */ +export function getEmbeddingResponseDiagnostic( + headers: Headers, + body: string +): EmbeddingResponseDiagnostic { + const diagnostic: EmbeddingResponseDiagnostic = { + providerRequestId: + safeRequestId(headers.get('x-request-id')) ?? + safeRequestId(headers.get('apim-request-id')) ?? + safeRequestId(headers.get('x-ms-request-id')), + bodyFormat: body ? 'non_json' : 'unavailable', + providerErrorCode: null, + providerErrorType: null, + } + if (!body) return diagnostic + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + return diagnostic + } + diagnostic.bodyFormat = 'json' + if (!parsed || typeof parsed !== 'object' || !('error' in parsed)) return diagnostic + const error = parsed.error + if (!error || typeof error !== 'object') return diagnostic + diagnostic.providerErrorCode = safeErrorCode('code' in error ? error.code : undefined) + diagnostic.providerErrorType = safeErrorCode( + 'type' in error ? error.type : 'status' in error ? error.status : undefined + ) + return diagnostic +} diff --git a/apps/sim/lib/knowledge/connectors/connector-error.test.ts b/apps/sim/lib/knowledge/connectors/connector-error.test.ts index a94305e22b0..e75747a877a 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.test.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.test.ts @@ -1,6 +1,7 @@ /** @vitest-environment node */ import { DrizzleQueryError } from 'drizzle-orm/errors' import { describe, expect, it } from 'vitest' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error' import { GoogleDriveApiError, @@ -9,6 +10,56 @@ import { import { ConnectorDirectoryError } from '@/connectors/source-error' describe('connector failure diagnostics', () => { + it('finds embedding failures through concurrent batches and nested cause wrappers', () => { + const error = new Error('private outer wrapper', { + cause: new AggregateError([ + new EmbeddingAPIError('private upstream message', 503), + new Error('private inner wrapper', { + cause: new AggregateError([new EmbeddingAPIError('private upstream message', 503)]), + }), + new Error('private sibling'), + ]), + }) + expect(getConnectorFailureDiagnostic(error)).toEqual({ + category: 'embedding', + status: 503, + message: 'Embedding service request failed (HTTP 503).', + }) + }) + + it('terminates cyclic aggregate wrappers and still finds an embedding sibling', () => { + const error = new AggregateError([]) + error.errors.push(new EmbeddingAPIError('private upstream message', 429), error) + error.cause = error + expect(getConnectorFailureDiagnostic(error)).toMatchObject({ + category: 'embedding', + status: 429, + }) + const cycle = new AggregateError([]) + cycle.errors.push(cycle) + expect(getConnectorFailureDiagnostic(cycle)).toBeNull() + }) + + it('bounds traversal of deeply nested aggregate wrappers', () => { + let error: Error = new EmbeddingAPIError('private upstream message', 503) + for (let i = 0; i < 40; i++) error = new AggregateError([error]) + expect(getConnectorFailureDiagnostic(error)).toBeNull() + }) + + it.each([401, 403, 404, 429, 502, 503])( + 'does not attribute a wrapped embedding HTTP %s to the source', + (status) => { + const error = new Error('private wrapper', { + cause: new EmbeddingAPIError('private provider details', status), + }) + expect(getConnectorFailureDiagnostic(error)).toEqual({ + category: 'embedding', + status, + message: `Embedding service request failed (HTTP ${status}).`, + }) + } + ) + it('retains the SQLSTATE while discarding SQL, bound values and driver detail', () => { const error = new DrizzleQueryError( 'select private_column from private_source where id = $1', diff --git a/apps/sim/lib/knowledge/connectors/connector-error.ts b/apps/sim/lib/knowledge/connectors/connector-error.ts index e82d9715f2c..b244bd808d9 100644 --- a/apps/sim/lib/knowledge/connectors/connector-error.ts +++ b/apps/sim/lib/knowledge/connectors/connector-error.ts @@ -1,5 +1,6 @@ import { findCause, getPostgresErrorCode } from '@sim/utils/errors' import { DrizzleQueryError } from 'drizzle-orm/errors' +import { getEmbeddingAPIError } from '@/lib/embeddings/api-error' import { ConnectorDirectoryError, ConnectorSourceError, @@ -8,7 +9,7 @@ import { } from '@/connectors/source-error' export interface ConnectorFailureDiagnostic { - category: 'directory' | 'database' | ConnectorSourceFailureCategory | 'transport' + category: 'directory' | 'database' | 'embedding' | ConnectorSourceFailureCategory | 'transport' message: string status?: number code?: string @@ -65,6 +66,19 @@ function classifyFailure(error: unknown): ConnectorFailureDiagnostic | null { if (databaseError) { return { category: 'database', message: 'Database request failed without a driver error code.' } } + const embeddingError = getEmbeddingAPIError(error) + if ( + embeddingError && + Number.isInteger(embeddingError.status) && + embeddingError.status >= 400 && + embeddingError.status <= 599 + ) { + return { + category: 'embedding', + status: embeddingError.status, + message: `Embedding service request failed (HTTP ${embeddingError.status}).`, + } + } const httpError = findCause( error, (value): value is Error & { status: number } => diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 1be1439f8f8..beb7e61e66a 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -85,8 +85,9 @@ import { BYOK_EMBEDDING_CREDENTIAL_REJECTION_MESSAGE, EMBEDDING_QUOTA_EXHAUSTED_MESSAGE, } from '@/lib/embeddings' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' import * as embeddingClient from '@/lib/embeddings/client' -import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' +import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' import { SYSTEM_ACCESS_SCOPE } from '@/lib/knowledge/access/types' import { PermanentDocumentProcessingError, diff --git a/apps/sim/lib/knowledge/documents/processing-provider-deferral.test.ts b/apps/sim/lib/knowledge/documents/processing-provider-deferral.test.ts index 49ff2d46e20..57348e4adf7 100644 --- a/apps/sim/lib/knowledge/documents/processing-provider-deferral.test.ts +++ b/apps/sim/lib/knowledge/documents/processing-provider-deferral.test.ts @@ -5,7 +5,8 @@ import { ProviderAdmissionTimeoutError, } from '@/lib/core/rate-limiter/provider-admission' import { ProviderCapacityDeferredError } from '@/lib/core/rate-limiter/provider-capacity-error' -import { EmbeddingAPIError, EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' +import { EmbeddingAPIError } from '@/lib/embeddings/api-error' +import { EmbeddingQuotaExhaustedError } from '@/lib/embeddings/client' import { OcrRequestRejectedError, PermanentDocumentProcessingError, From cbdb864dedb47cb55298c7d03a0c94d0060f8e08 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 17 Sep 2026 14:57:46 -0700 Subject: [PATCH 2/8] fix(search): dock the search header after results arrive (#7948) --- .../o/[organizationId]/search/search.test.tsx | 45 ++++++++ .../app/o/[organizationId]/search/search.tsx | 108 ++++++++++++------ .../knowledge-search-results.test.tsx | 6 +- .../knowledge-search-results.tsx | 51 +++++---- .../search-transitions.test.tsx | 87 +++++++++++++- 5 files changed, 239 insertions(+), 58 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index f6c9d62e804..9d21df13fc8 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -220,3 +220,48 @@ describe('organization Search query navigation', () => { expectVisibleQuery('Orion') }) }) + +describe('organization Search header placement', () => { + it.each([ + ['pending', { isPending: true, isFetching: true }], + ['failed', { isError: true, isPending: false }], + ['empty', { data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } } }], + [ + 'timed out', + { data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] } } }, + ], + ])('keeps the initial %s search in the centered layout', async (_state, response) => { + mocks.search.mockReturnValue(response) + await render('?q=Orion') + expect(container.querySelector('h1')?.textContent).toBe('Search Acme') + expect(container.querySelector('[aria-label="Search results"]')).toBeNull() + expect(document.activeElement).toBe(searchInput()) + }) + + it('docks only when results arrive without replacing the field or losing a draft', async () => { + const completed = mocks.search(scope, 'Orion') + mocks.search.mockReturnValue({ isPending: true, isFetching: true }) + await render('?q=Orion') + const input = searchInput() + const filters = container.querySelector('[aria-label="Search filters"]') + await editDraft('Unsubmitted draft') + mocks.search.mockReturnValue(completed) + await render('?q=Orion') + expect(container.querySelector('h1')).toBeNull() + expect(searchInput()).toBe(input) + expect(input.value).toBe('Unsubmitted draft') + expect(document.activeElement).toBe(input) + expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters) + + mocks.search.mockReturnValue({ + data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } }, + }) + await render('?q=Orion') + expect(container.querySelector('h1')).toBeNull() + expect(searchInput()).toBe(input) + + await render('?q=Vega') + expect(container.querySelector('h1')?.textContent).toBe('Search Acme') + expect(searchInput().value).toBe('Vega') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index 7ab8ac1173d..0485bbb70ec 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { Button, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' import { ArrowUp, Search } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' @@ -122,7 +122,7 @@ function SearchField({ /** * Sim Search over the organization's sources. Empty, it is the greeting over the - * query field, centered like Home; once a query is submitted the field docks at + * query field, centered like Home; once results arrive the field docks at * the top of the page — where every other organization page's title sits — and * the results scroll beneath it under the sidebar's edge fade. The submitted * query lives in the URL; the field holds the draft until the next submit. @@ -141,10 +141,6 @@ function OrganizationSearchContent() { const query = q.trim() const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } - const scrollContainerRef = useRef(null) - const scrollContentRef = useRef(null) - const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) - const summarize = (message: string, assistantSearch: WorkspaceSearchFilters) => { MothershipHandoffStorage.store( { message, assistantSearch }, @@ -159,49 +155,95 @@ function OrganizationSearchContent() { void setParams({ q: next }) } - const searching = query.length > 0 + const renderLayout = (results: ReactNode, docked: boolean) => ( + + {results} + + ) + + return query ? ( + + ) : ( + renderLayout(null, false) + ) +} + +interface SearchLayoutProps { + query: string + onSubmit: (draft: string) => void + docked: boolean + children: ReactNode +} + +function SearchLayout({ query, onSubmit, docked, children }: SearchLayoutProps) { + const { organization } = useOrganizationContext() + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) return (
- {/* Reserved even while empty so the field docks where the page header sits. */}
- {searching ? ( - <> -
- +
+
+
+ {!docked && ( +

+ Search {organization.name} +

+ )} +
- {/* The rows carry their own `px-2`; this gutter brings each row's mark under the - field's own search glyph, so results read as a column hanging from the field. */} -
- -
-
- - ) : ( -
- {/* Asymmetric padding biases the group up so heading and field sit at the optical center, as on Home */} -
-

- Search {organization.name} -

-
- +
+ {children}
- )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx index 2264db73f56..c4a857f3c7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx @@ -87,6 +87,9 @@ describe('incomplete search coverage', () => { it.each([false, true])( 'distinguishes incomplete retrieval and permits retry (hasResults=%s)', async (hasResults) => { + mocks.overview.mockReturnValue({ + data: { providers: [{ connectorType: 'gmail', isSyncing: true }] }, + }) mocks.search.mockReturnValue({ data: { query: 'launch', @@ -118,9 +121,10 @@ describe('incomplete search coverage', () => { expect(container.textContent).not.toContain('Search couldn’t run') expect(container.textContent).not.toContain('No documents') expect(container.textContent).toContain( - hasResults ? '1 document · some results may be missing.' : 'Search didn’t finish.' + hasResults ? '1 document · some results may be missing.' : 'Search timed out.' ) expect(container.textContent).not.toContain('Search found no results.') + expect(container.textContent).not.toContain('Still indexing') if (hasResults) expect(container.textContent).toContain('Release plan') const retry = [...container.querySelectorAll('button')].find( (button) => button.textContent === 'Try again' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index b4bff7f98dd..dfce603fb86 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { type ReactNode, useState } from 'react' import { Chip, ChipLink, cn } from '@sim/emcn' import { useQueryStates } from 'nuqs' import { ActivityStatus } from '@/components/ui/activity-status' @@ -94,6 +94,8 @@ type KnowledgeSearchResultsProps = ( | { scope: ResourceScope; workspaceId?: never } ) & { query: string + /** Lets the page dock its header after this query has displayed results. */ + renderLayout?: (results: ReactNode, hasDisplayedResults: boolean) => ReactNode /** Binds the Assistant turn to the selected canonical document. */ onSummarize: (prompt: string, filters: WorkspaceSearchFilters) => void } @@ -104,6 +106,7 @@ export function KnowledgeSearchResults({ scope: suppliedScope, query, onSummarize, + renderLayout, }: KnowledgeSearchResultsProps) { const scope: ResourceScope = suppliedScope ?? { kind: 'workspace', workspaceId: workspaceId! } const { data: session } = useSession() @@ -114,6 +117,7 @@ export function KnowledgeSearchResults({ scope={scope} query={trimmed} onSummarize={onSummarize} + renderLayout={renderLayout} /> ) } @@ -122,9 +126,11 @@ interface SearchResultsProps { scope: ResourceScope query: string onSummarize: KnowledgeSearchResultsProps['onSummarize'] + renderLayout: KnowledgeSearchResultsProps['renderLayout'] } -function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { +function SearchResults({ scope, query, onSummarize, renderLayout }: SearchResultsProps) { + const [hasDisplayedResults, setHasDisplayedResults] = useState(false) const [searchedAt] = useState(Date.now) const { data: index, @@ -168,28 +174,28 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { const partial = search?.retrieval.status === 'partial' const documentCount = documents.length === 1 ? '1 document' : `${documents.length} documents` - if (noSources) { - return ( -
-

No sources are set up yet.

- - View sources - -
- ) - } const indexingNote = indexing.length > 0 ? `Still indexing ${indexing.join(', ')}; results grow as documents land.` : null - return ( + const showResults = !noSources && !failed && !basesPending && documents.length > 0 + if (showResults && !hasDisplayedResults) setHasDisplayedResults(true) + + const content = noSources ? ( +
+

No sources are set up yet.

+ + View sources + +
+ ) : (
@@ -201,14 +207,14 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { ? 'Search couldn’t run.' : partial ? documents.length === 0 - ? 'Search didn’t finish.' + ? 'Search timed out.' : `${documentCount} · some results may be missing.` : documents.length === 0 ? 'Search found no results.' : `${documentCount} · searched as you`}

)} - {indexingNote && !failed && ( + {indexingNote && !failed && !partial && (

{indexingNote}

)}
@@ -259,7 +265,7 @@ function SearchResults({ scope, query, onSummarize }: SearchResultsProps) { ))}
- {!failed && !basesPending && documents.length > 0 && ( + {showResults && (
) + return renderLayout ? renderLayout(content, hasDisplayedResults || showResults) : content } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx index f6f22427549..d5c2d33f965 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/search-transitions.test.tsx @@ -15,6 +15,19 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { id: mocks.userId } } }), })) vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/o/organization/search', +})) +vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ + useOrganizationContext: () => ({ + organization: { id: 'organization', name: 'Acme' }, + searchAccess: { memberScoped: true }, + }), +})) +vi.mock('@/hooks/use-speech-to-text', () => ({ + useSpeechToText: () => ({ isSupported: false }), +})) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }), useSearchSourceOverview: () => ({ @@ -56,6 +69,7 @@ import type { WorkspaceKnowledgeSearchData, } from '@/lib/api/contracts/knowledge' import type { ResourceScope } from '@/lib/core/resource-scope' +import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -102,16 +116,22 @@ async function render({ scope = { kind: 'organization', organizationId: 'organization' }, query = 'launch', params = '', + organizationPage = false, }: { scope?: ResourceScope query?: string params?: string + organizationPage?: boolean } = {}) { await act(async () => { root.render( - + {organizationPage ? ( + + ) : ( + + )} ) @@ -170,6 +190,69 @@ async function complete( } describe('search refinement with the real query cache and URL state', () => { + it('keeps the organization header docked while source and date changes run filtered searches', async () => { + await render({ organizationPage: true, params: '?q=launch' }) + expect(container.querySelector('h1')?.textContent).toBe('Search Acme') + await complete(0) + expect(container.querySelector('h1')).toBeNull() + const input = container.querySelector('input') + const filters = container.querySelector('[aria-label="Search filters"]') + + for (const [label, expectedFilters] of [ + ['Gmail', { source: 'gmail' }], + ['Past week', { source: 'gmail', modifiedAfter: '2026-01-08T12:00:00.000Z' }], + ['Past month', { source: 'gmail', modifiedAfter: '2025-12-16T12:00:00.000Z' }], + ] as const) { + const previousRequests = requests.length + const control = button(label) + await click(label) + expect(requests).toHaveLength(previousRequests + 1) + expect(requests.at(-1)?.body).toEqual({ + organizationId: 'organization', + query: 'launch', + filters: expectedFilters, + }) + expect(container.querySelector('h1')).toBeNull() + expect(container.querySelector('input')).toBe(input) + expect(container.querySelector('[aria-label="Search filters"]')).toBe(filters) + expect(document.activeElement).toBe(control) + expect(container.textContent).toContain('Updating results…') + expect( + container.querySelector('[aria-label="Search results"]')?.getAttribute('aria-busy') + ).toBe('true') + expect(container.querySelector('a[data-source-link]')).not.toBeNull() + await complete(previousRequests, { title: `${label} result` }) + expect(container.querySelector('a[data-source-link]')?.textContent).toBe(`${label} result`) + expect(document.activeElement).toBe(control) + expect(container.querySelector('h1')).toBeNull() + } + }) + + it.each(['empty', 'timeout', 'error'] as const)( + 'keeps the organization header docked when a refinement returns %s', + async (outcome) => { + await render({ organizationPage: true, params: '?q=launch' }) + await complete(0) + const gmail = button('Gmail') + await click('Gmail') + if (outcome === 'error') { + await act(async () => { + requests[1].reject(new Error('Search failed')) + await vi.advanceTimersByTimeAsync(1) + }) + } else { + await complete(1, { empty: true, partial: outcome === 'timeout' }) + } + expect(container.querySelector('h1')).toBeNull() + expect(button('Gmail')).toBe(gmail) + expect(document.activeElement).toBe(gmail) + expect(container.textContent).not.toContain('Release plan') + await click('All sources') + expect(container.textContent).toContain('Release plan') + expect(container.querySelector('h1')).toBeNull() + } + ) + it('replaces filter URL state while preserving unrelated parameters', async () => { await render({ params: '?q=launch&panel=details' }) await click('Gmail') @@ -302,7 +385,7 @@ describe('search refinement with the real query cache and URL state', () => { await render() await complete(0, { partial: true, empty }) expect(container.textContent).toContain( - empty ? 'Search didn’t finish.' : 'some results may be missing.' + empty ? 'Search timed out.' : 'some results may be missing.' ) expect(container.textContent).not.toContain('Search found no results.') const gmail = button('Gmail') From 7acae58533270c209edea461de574673be5025a1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 17 Sep 2026 15:32:27 -0700 Subject: [PATCH 3/8] fix(knowledge): share compact vector retrieval across access scopes (#7944) * fix(knowledge): share compact vector retrieval across access scopes * fix(knowledge): align search fixtures with shared retrieval * fix(knowledge): preserve recall within bounded reranking * fix(knowledge): validate bounded compact scan plans * fix(knowledge): batch workspace search refills --- .../app/api/knowledge/search/utils.test.ts | 34 +- .../kb-block-search.integration.ts | 2 +- .../search-latency.integration.ts | 332 ++++++++++- apps/sim/lib/knowledge/search/budget.test.ts | 2 +- apps/sim/lib/knowledge/search/diagnostics.ts | 2 +- apps/sim/lib/knowledge/search/queries.test.ts | 531 ++++++++++-------- apps/sim/lib/knowledge/search/queries.ts | 263 +++------ 7 files changed, 682 insertions(+), 484 deletions(-) diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index fc19016b170..7106308b9d5 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -217,6 +217,7 @@ describe('Knowledge Search Utils', () => { Array.from({ length: 201 }, (_, index) => ({ id: `candidate-${index}` })) ) queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) + queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) const results = await handleTagAndVectorSearch({ knowledgeBaseIds: ['kb-1', 'kb-2'], @@ -231,11 +232,10 @@ describe('Knowledge Search Utils', () => { expect(results.map((row) => row.id)).toEqual(['first', 'second']) expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) - expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) - expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 201) + expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 400) expect(dbChainMockFns.select.mock.calls[1][0]).toHaveProperty('distance') - expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(20) }) it('should throw error when no filters provided', async () => { @@ -463,11 +463,7 @@ describe('Knowledge Search Utils', () => { queryVector: JSON.stringify([0.1, 0.2, 0.3]), }) - /** - * A single global LIMIT would let the lexically strongest base consume - * every slot, so an exact-token hit in a smaller base never reaches - * fusion. The vector leg already fans out here; both legs must match. - */ + /** Keyword retrieval preserves its existing per-base lexical candidate selection. */ expect(dbChainMockFns.select).toHaveBeenCalledTimes(knowledgeBaseIds.length) }) @@ -542,6 +538,7 @@ describe('Knowledge Search Utils', () => { }) it('runs a single retrieval leg in vector mode', async () => { + dbChainMockFns.execute.mockResolvedValue([{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) @@ -551,22 +548,22 @@ describe('Knowledge Search Utils', () => { topK: 10, searchMode: 'vector', query: 'PROJ-1234', - queryVector: JSON.stringify([0.1, 0.2, 0.3]), + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, }) expect(results.map((r) => r.id)).toEqual(['vector-hit']) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) - expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) }) it('runs both legs and fuses them in hybrid mode', async () => { /** - * Chains dequeue in creation order: keyword ranking, the budgeted vector - * probe, keyword hydration, then vector ranking and hydration in one query. + * The raw vector probe does not consume a table chain. Keyword ranking and + * hydration complete before vector exact ranking and content hydration. */ + dbChainMockFns.execute.mockResolvedValue([{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [{ id: 'keyword-hit', keywordRank: 0.9 }]) - queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('keyword-hit')]) + queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) const results = await executeKnowledgeSearch({ @@ -575,15 +572,16 @@ describe('Knowledge Search Utils', () => { topK: 10, searchMode: 'hybrid', query: 'PROJ-1234', - queryVector: JSON.stringify([0.1, 0.2, 0.3]), + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, }) expect(results.map((r) => r.id).sort()).toEqual(['keyword-hit', 'vector-hit']) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(5) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(4) }) it('propagates unexpected keyword errors after the vector leg finishes', async () => { /** The failing ranking chain is still built first and takes the first queued set. */ + dbChainMockFns.execute.mockResolvedValue([{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [{ id: 'never-ranked', keywordRank: 0 }]) queueTableRows(schemaMock.embedding, [{ id: 'vector-hit' }]) queueTableRows(schemaMock.embedding, [makeResult('vector-hit')]) @@ -600,10 +598,10 @@ describe('Knowledge Search Utils', () => { topK: 10, searchMode: 'hybrid', query: 'PROJ-1234', - queryVector: JSON.stringify([0.1, 0.2, 0.3]), + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, }) ).rejects.toBe(failure) - expect(dbChainMockFns.as).toHaveBeenCalledWith('ranked_embeddings') + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) }) it('skips both query legs when only tag filters are provided', async () => { diff --git a/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts index dba85e74b3d..b54266037d7 100644 --- a/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/kb-block-search.integration.ts @@ -124,7 +124,7 @@ describe('API-key KB block fan-out', () => { expect(result.rows[0].knowledgeBaseId).toBe(bases[index].id) expect(result.rows[0].distance).toBeCloseTo(0) } - expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(36) + expect(statements.filter((query) => query.includes('statement_timeout'))).toHaveLength(54) expect(statements.filter((query) => query.includes('+ 0'))).toHaveLength(18) expect(statements.some((query) => query.includes('hnsw.iterative_scan'))).toBe(false) } finally { diff --git a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts index 0c89994ee4f..a5edda72119 100644 --- a/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-latency.integration.ts @@ -1,5 +1,6 @@ -/** Real Assistant tool, application authorization, PostgreSQL/pgvector, and result processing. */ +/** Real search adapters, application authorization, PostgreSQL/pgvector, and result processing. */ import { readFileSync, statSync, writeFileSync } from 'node:fs' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -39,6 +40,7 @@ import { seedKnowledgeAclFixture, seedKnowledgeMemberFixture, } from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { type KnowledgeSearchTagFilter, searchKnowledge } from '@/lib/knowledge/application/search' import { SearchBudget, SearchDeadlineError, @@ -54,6 +56,7 @@ vi.hoisted(() => { if (process.env.KNOWLEDGE_SEARCH_PERFORMANCE_TEST === 'true') { Object.assign(process.env, { OPENAI_API_KEY: 'isolated-embedding-http-fixture', + GEMINI_API_KEY: 'isolated-gemini-http-fixture', CONFLUENCE_CLIENT_ID: 'isolated-confluence-fixture-client', CONFLUENCE_CLIENT_SECRET: 'isolated-confluence-fixture-secret', }) @@ -72,6 +75,7 @@ const unrelatedChunkCount = Number( const evictSharedBuffers = process.env.KNOWLEDGE_SEARCH_PERFORMANCE_EVICT_BUFFERS === 'true' const dimensions = 1536 const candidateDimensions = 512 +const HYBRID_CANDIDATE_LIMIT = 1600 const chunksPerDocument = 4 const logger = createLogger('SearchLatencyIntegration') const fixtureSchema = z.object({ @@ -93,6 +97,7 @@ function readFixtureReport(file: string) { .object({ fixture: fixtureSchema, unrelatedFixture: fixtureSchema, + fullWidthFixture: fixtureSchema.optional(), method: z.object({ fixtureVersion: z.literal(2) }), }) .parse(JSON.parse(readFileSync(file, 'utf8'))) @@ -100,6 +105,8 @@ function readFixtureReport(file: string) { const reused = reuseFile ? readFixtureReport(reuseFile) : undefined const ids = reused?.fixture ?? createKnowledgeAclFixtureIds() const unrelated = reused?.unrelatedFixture ?? createKnowledgeAclFixtureIds() +const fullWidthFixture = reused?.fullWidthFixture ?? createKnowledgeAclFixtureIds() +const FULL_WIDTH_CHUNK_COUNT = 5000 const organizationChatId = generateId() function topicVector(topic = 0) { const vector = Array.from({ length: dimensions }, (_, index) => @@ -117,20 +124,22 @@ const captured: CapturedQuery[] = [] const report: Record = { fixture: ids, unrelatedFixture: unrelated, + fullWidthFixture, method: { fixtureVersion: 2, chunkCount, unrelatedChunkCount, + fullWidthChunkCount: FULL_WIDTH_CHUNK_COUNT, dimensions, candidateDimensions, chunksPerDocument, - sql: 'Captured from the real Assistant tool; no hand-written search query', + sql: 'Captured from real search application adapters; no hand-written retrieval query', providers: 'Embedding and source-permission HTTP responses are controlled; internal search and authorization code is real', vectors: 'Normalized 512-dimensional topic/noise geometry with permuted copies across 1536 dimensions; verifies prefix candidate ranking, not semantic embedding quality', cache: evictSharedBuffers - ? 'Organization samples evict PostgreSQL shared buffers before each request; operating-system cache is not cleared' + ? 'Selected workspace and organization samples evict PostgreSQL shared buffers; operating-system cache is not cleared' : 'First and repeated samples; no claim of a cold operating-system cache', layout: reused ? 'Reused fixture; physical layout is inherited from its original report' @@ -153,6 +162,7 @@ interface ExplainNode { 'Node Type': string 'Actual Rows': number 'Actual Loops': number + 'Rows Removed by Filter'?: number 'Plan Rows'?: number 'Shared Hit Blocks'?: number 'Shared Read Blocks'?: number @@ -170,6 +180,7 @@ const explainNodeSchema: z.ZodType = z.lazy(() => 'Node Type': z.string(), 'Actual Rows': z.number(), 'Actual Loops': z.number(), + 'Rows Removed by Filter': z.number().optional(), 'Plan Rows': z.number().optional(), 'Shared Hit Blocks': z.number().optional(), 'Shared Read Blocks': z.number().optional(), @@ -197,16 +208,21 @@ function explainNodes(node: ExplainNode): ExplainNode[] { } /** Broad ranking must stop the ordered ANN scan instead of sorting every accessible chunk. */ -function assertIndexedCandidates(plan: ExplainNode, candidateLimit: number) { +function assertIndexedCandidates( + plan: ExplainNode, + candidateLimit: number, + width = candidateDimensions +) { + const indexName = + width === 1536 + ? 'embedding_search_cosine_hnsw_idx' + : `embedding_search_${width}_cosine_hnsw_idx` const nodes = explainNodes(plan) const initial = nodes.find((node) => node['Subplan Name'] === 'CTE initial_candidates') expect(initial).toBeDefined() const candidateNodes = explainNodes(initial!) expect( - candidateNodes.some( - (node) => - node['Index Name'] === 'embedding_search_512_cosine_hnsw_idx' && node['Actual Loops'] > 0 - ) + candidateNodes.some((node) => node['Index Name'] === indexName && node['Actual Loops'] > 0) ).toBe(true) expect(candidateNodes.some((node) => node['Node Type'] === 'Sort')).toBe(false) expect( @@ -272,7 +288,7 @@ async function prepareOrganizationSample(label: string) { const diagnosticSchema = z .object({ - surface: z.enum(['dashboard', 'copilot']), + surface: z.enum(['dashboard', 'copilot', 'workflow', 'api']), outcome: z.enum(['success', 'partial']), elapsedMs: z.number(), vectorBudgetMs: z.number().positive(), @@ -301,7 +317,12 @@ const resultSchema = z.object({ success: z.literal(true), data: z.object({ results: z.array( - z.object({ documentId: z.string(), content: z.string(), knowledgeBaseId: z.string() }) + z.object({ + documentId: z.string(), + content: z.string(), + knowledgeBaseId: z.string(), + embeddingId: z.string().optional(), + }) ), }), }) @@ -368,6 +389,34 @@ async function searchDashboard( } } +async function searchWorkspaceKb( + query = 'Orion deployment', + options: { + principal?: Principal + tagFilters?: KnowledgeSearchTagFilter[] + fixture?: typeof ids + } = {} +) { + const fixture = options.fixture ?? ids + const result = await searchKnowledge.execute({ + principal: options.principal ?? { + kind: 'workspace_api_key', + workspaceId: fixture.workspaceId, + keyId: 'fixture-search-key', + }, + input: { + workspaceId: fixture.workspaceId, + knowledgeBaseIds: [fixture.knowledgeBaseId], + query, + topK: 15, + searchMode: 'vector', + surface: 'workflow', + tagFilters: options.tagFilters, + }, + }) + return resultSchema.parse({ success: true, data: result }) +} + /** Allow either index or filtered plans, but require successful retrieval within the real surface budget. */ function expectCompleteVectorSearch(diagnostics: z.infer) { const budget = diagnostics.surface === 'dashboard' ? 3000 : 8000 @@ -383,7 +432,7 @@ function expectCompleteVectorSearch(diagnostics: z.infer ReturnType, - options: { explain?: boolean } = {} + options: { explain?: boolean; candidateScanRowLimit?: number } = {} ) { captured.length = 0 diagnosticLog?.mockClear() @@ -478,10 +527,29 @@ async function sample( }) saveReport() if (query.query.includes('WITH visible_search_documents')) { - expect(query.query).toContain('"embedding_search"."vector_512"') - expect(diagnostics.vectorCandidateDimensions).toBe(candidateDimensions) + const width = diagnostics.vectorCandidateDimensions! + expect(query.query).toContain( + `"embedding_search"."${width === 1536 ? 'vector' : `vector_${width}`}"` + ) expect(diagnostics.vectorCandidateLimit).toBeGreaterThan(0) - assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!) + if (options.candidateScanRowLimit !== undefined) { + /** A small model-specific projection can be cheaper to rank through its KB index. */ + assertCompactCandidates(parsedPlan[0].Plan) + const scans = explainNodes(parsedPlan[0].Plan).filter( + (node) => node['Relation Name'] === 'embedding_search' && node['Actual Loops'] > 0 + ) + expect(scans.length).toBeGreaterThan(0) + for (const node of scans) { + const visited = + (node['Actual Rows'] + (node['Rows Removed by Filter'] ?? 0)) * node['Actual Loops'] + /** EXPLAIN rounds per-worker row averages to integers. */ + expect(visited).toBeLessThanOrEqual( + options.candidateScanRowLimit + node['Actual Loops'] - 1 + ) + } + } else { + assertIndexedCandidates(parsedPlan[0].Plan, diagnostics.vectorCandidateLimit!, width) + } } if (query.query.includes('WITH visible_keyword_documents')) { assertScalarKeywordSorts(parsedPlan[0].Plan) @@ -495,7 +563,7 @@ async function sample( return { result, plans, diagnostics } } -describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpus', () => { +describe.skipIf(!enabled)('Knowledge search latency on a realistic indexed corpus', () => { beforeAll(async () => { if ( [chunkCount, unrelatedChunkCount].some( @@ -520,6 +588,29 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu ? new Response(null, { status: 403 }) : Response.json({ type: 'known', accountId: ids.aliceId }) } + if ( + url === + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents' + ) { + z.object({ + requests: z + .array( + z.object({ + model: z.literal('models/gemini-embedding-001'), + content: z.object({ + parts: z.array(z.object({ text: z.literal('Orion deployment') })), + }), + outputDimensionality: z.literal(dimensions), + }) + ) + .length(1), + }).parse(JSON.parse(String(init?.body))) + embeddingCalls++ + return Response.json({ + embeddings: [{ values: queryVector }], + usageMetadata: { promptTokenCount: 4 }, + }) + } if (url !== 'https://api.openai.com/v1/embeddings') throw new Error(`Unexpected outbound request in search fixture: ${new URL(url).origin}`) const body = z @@ -645,6 +736,41 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu logger.info('Synthetic corpora loaded', { chunkCount, unrelatedChunkCount }) for (const index of indexes) await db.execute(sql.raw(index.indexdef)) } + /** A non-shortenable model must populate its own full-width projection through the write trigger. */ + if (!reused?.fullWidthFixture) { + await seedKnowledgeAclFixture(fullWidthFixture, { connectorType: 'google_drive' }) + await db + .update(knowledgeBase) + .set({ embeddingModel: 'gemini-embedding-001' }) + .where(eq(knowledgeBase.id, fullWidthFixture.knowledgeBaseId)) + await db.execute(sql` + WITH source AS MATERIALIZED ( + SELECT id, content, embedding FROM embedding + WHERE knowledge_base_id = ${ids.knowledgeBaseId} ORDER BY id LIMIT ${FULL_WIDTH_CHUNK_COUNT} + ), documents AS ( + INSERT INTO document + (id, knowledge_base_id, connector_id, external_id, filename, file_url, file_size, + mime_type, processing_status, acl, acl_verified_at) + SELECT ${fullWidthFixture.workspaceId} || '-doc-' || id, ${fullWidthFixture.knowledgeBaseId}, + ${fullWidthFixture.connectorId}, id, 'Full-width deployment guide', + 'https://fixture.invalid/full-width', 12000, 'text/plain', 'completed', + ARRAY['pub']::text[], statement_timestamp() FROM source RETURNING id + ) INSERT INTO embedding + (id, knowledge_base_id, document_id, chunk_index, chunk_hash, content, content_length, + token_count, start_offset, end_offset, embedding) + SELECT ${fullWidthFixture.workspaceId} || '-chunk-' || source.id, + ${fullWidthFixture.knowledgeBaseId}, documents.id, 0, source.id, source.content, + 3000, 750, 0, 3000, source.embedding + FROM source JOIN documents ON documents.id = ${fullWidthFixture.workspaceId} || '-doc-' || source.id + `) + } + const [fullWidthSize] = await db.execute<{ count: number }>( + sql`SELECT count(*)::int AS count FROM embedding WHERE knowledge_base_id = ${fullWidthFixture.knowledgeBaseId}` + ) + expect(fullWidthSize.count).toBe(FULL_WIDTH_CHUNK_COUNT) + await db.execute(sql`UPDATE embedding SET tag1 = 'selected' WHERE knowledge_base_id = ${ids.knowledgeBaseId} + AND tag1 IS DISTINCT FROM 'selected' + AND document_id IN (SELECT id FROM document WHERE knowledge_base_id = ${ids.knowledgeBaseId} AND external_id::int < 600)`) await db.execute(sql`ANALYZE document`) await db.execute(sql`ANALYZE embedding`) await db.execute(sql`ANALYZE embedding_search`) @@ -674,7 +800,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu saveReport() for (const fixture of process.env.KNOWLEDGE_SEARCH_PERFORMANCE_KEEP_DATABASE === 'true' ? [] - : [ids, unrelated]) { + : [ids, unrelated, fullWidthFixture]) { await db.delete(workspace).where(eq(workspace.id, fixture.workspaceId)) await db.delete(organization).where(eq(organization.id, fixture.organizationId)) await db.delete(user).where(eq(user.id, fixture.aliceId)) @@ -1015,7 +1141,7 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu } }, 180_000) - it.each([200, 396, 400, 1000, 2000])( + it.each([200, 1000, 1596, 1600, 2000])( 'keeps a selective scope of %s chunks within both retrieval budgets', async (count) => { const documentCount = count / chunksPerDocument @@ -1042,10 +1168,14 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu true ) const probe = plans.find((plan) => plan.kind === 'probe')! - expect(probe.plan[0].Plan['Actual Rows']).toBe(Math.min(count, 400)) - expect(assertIndexedChunkProbe(probe.plan[0].Plan)).toBe(Math.min(documentCount, 100)) - expect(plans.filter((plan) => plan.kind === 'vector')).toHaveLength(count < 400 ? 0 : 1) - if (count > 400) { + expect(probe.plan[0].Plan['Actual Rows']).toBe(Math.min(count, HYBRID_CANDIDATE_LIMIT)) + expect(assertIndexedChunkProbe(probe.plan[0].Plan)).toBe( + Math.min(documentCount, HYBRID_CANDIDATE_LIMIT / chunksPerDocument) + ) + expect(plans.filter((plan) => plan.kind === 'vector')).toHaveLength( + count < HYBRID_CANDIDATE_LIMIT ? 0 : 1 + ) + if (count > HYBRID_CANDIDATE_LIMIT) { const rerank = plans.find((plan) => plan.kind === 'rerank')! const actual = await db.$client.unsafe(rerank.query, rerank.parameters).values() const expected = await db.execute<{ id: string }>(sql`SELECT id FROM embedding @@ -1113,8 +1243,10 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu expectCompleteVectorSearch(broad.diagnostics) expect(broad.result.data.results).toHaveLength(15) const broadProbe = broad.plans.find((plan) => plan.kind === 'probe')! - expect(broadProbe.plan[0].Plan['Actual Rows']).toBe(400) - expect(assertIndexedChunkProbe(broadProbe.plan[0].Plan)).toBe(400 / chunksPerDocument) + expect(broadProbe.plan[0].Plan['Actual Rows']).toBe(HYBRID_CANDIDATE_LIMIT) + expect(assertIndexedChunkProbe(broadProbe.plan[0].Plan)).toBe( + HYBRID_CANDIDATE_LIMIT / chunksPerDocument + ) const { result, plans, diagnostics } = await sample(`member-scope.${surface}`, () => surface === 'copilot' ? search(ids.bobId) : searchDashboard('Orion deployment', ids.bobId) ) @@ -1201,6 +1333,160 @@ describe.skipIf(!enabled)('Assistant search latency on a realistic indexed corpu for (const diagnostics of completed) expectCompleteVectorSearch(diagnostics) }, 180_000) + it('uses compact indexed ranking for workspace KBs with stale estimates and private neighbors', async () => { + const originalAcl = `u:${ids.aliceId}@fixture.test` + await db.execute(sql`ALTER TABLE document SET (autovacuum_enabled = false)`) + try { + /** Analyze a narrow scope, then grow it without updating the planner's ACL histogram. */ + await db.execute(sql`UPDATE document SET acl = CASE WHEN external_id::int % 10 = 1 + THEN ARRAY['pub'] ELSE ARRAY[${originalAcl}] END + WHERE knowledge_base_id = ${ids.knowledgeBaseId}`) + await db.execute(sql`ANALYZE document`) + await db.execute(sql`UPDATE document SET acl = CASE WHEN external_id::int % 5 <> 0 + THEN ARRAY['pub'] ELSE ARRAY[${originalAcl}] END + WHERE knowledge_base_id = ${ids.knowledgeBaseId}`) + for (const topic of [0, 11, 23]) { + const label = `workspace-kb.topic.${topic}` + await prepareOrganizationSample(label) + const { result, plans, diagnostics } = await sample(label, () => + searchWorkspaceKb(`Topic ${topic} deployment`) + ) + expectCompleteVectorSearch(diagnostics) + expect(diagnostics.accessScopeKind).toBe('workspace') + expect(diagnostics.vectorRanking).toBe('candidate-rerank') + expect(result.data.results).toHaveLength(15) + expect(plans.some((plan) => plan.kind === 'vector')).toBe(true) + for (const row of result.data.results) { + expect(row.knowledgeBaseId).toBe(ids.knowledgeBaseId) + expect(Number(row.documentId.split('-doc-')[1]) % 5).not.toBe(0) + } + const expected = await db.execute<{ id: string }>(sql` + SELECT embedding.id FROM embedding JOIN document ON document.id = embedding.document_id + WHERE embedding.knowledge_base_id = ${ids.knowledgeBaseId} AND embedding.enabled + AND document.acl = ARRAY['pub']::text[] + ORDER BY (embedding.embedding <=> ${JSON.stringify(topicVector(topic))}::vector) + 0, embedding.id + LIMIT 15 + `) + const expectedIds = new Set(expected.map(({ id }) => id)) + const recall = + result.data.results.filter((row) => expectedIds.has(row.embeddingId!)).length / + expected.length + expect(recall).toBeGreaterThanOrEqual(0.95) + report[`${label}.recall`] = { neighbors: expected.length, recall } + saveReport() + } + const fullWidth = await sample( + 'workspace-kb.full-width', + () => searchWorkspaceKb('Orion deployment', { fixture: fullWidthFixture }), + { candidateScanRowLimit: FULL_WIDTH_CHUNK_COUNT } + ) + expectCompleteVectorSearch(fullWidth.diagnostics) + expect(fullWidth.diagnostics.vectorCandidateDimensions).toBe(dimensions) + expect(fullWidth.result.data.results).toHaveLength(15) + + const workflowId = generateId() + const scheduled: Principal = { + kind: 'delegated', + serviceId: 'executor', + workspaceId: ids.workspaceId, + delegationId: generateId(), + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + delegationContext: { + kind: 'workflow_execution', + workflowId, + principal: { + kind: 'system', + serviceId: 'schedule', + workspaceId: ids.workspaceId, + workflowId, + }, + currentWorkflow: { workflowId, mode: 'deployment', deploymentVersionId: generateId() }, + }, + } + const scheduledResult = await sample('workspace-kb.scheduled', () => + searchWorkspaceKb('Orion deployment', { principal: scheduled }) + ) + expectCompleteVectorSearch(scheduledResult.diagnostics) + expect(scheduledResult.diagnostics.accessScopeKind).toBe('workspace') + expect(scheduledResult.result.data.results).toHaveLength(15) + + for (const concurrency of [2, 8]) { + const label = `workspace-kb.concurrent.${concurrency}` + await prepareOrganizationSample(label) + diagnosticLog?.mockClear() + const started = performance.now() + const results = await Promise.all( + Array.from({ length: concurrency }, (_, index) => + searchWorkspaceKb(`Topic ${index * 3} deployment`) + ) + ) + const completed = diagnosticLog!.mock.calls + .filter(([message]) => message === 'Knowledge search completed') + .map(([, metadata]) => diagnosticSchema.parse(metadata)) + report[label] = { + milliseconds: performance.now() - started, + resultCounts: results.map((result) => result.data.results.length), + diagnostics: completed, + } + saveReport() + expect(completed).toHaveLength(concurrency) + for (const result of results) expect(result.data.results).toHaveLength(15) + for (const diagnostics of completed) expectCompleteVectorSearch(diagnostics) + } + + const tagged = await sample('workspace-kb.tagged', () => + searchWorkspaceKb('Orion deployment', { + tagFilters: [{ tagName: 'Fixture', operator: 'eq', value: 'selected' }], + }) + ) + expectCompleteVectorSearch(tagged.diagnostics) + expect(tagged.diagnostics.vectorRanking).toBe('candidate-rerank') + expect(tagged.result.data.results).toHaveLength(15) + for (const row of tagged.result.data.results) { + const ordinal = Number(row.documentId.split('-doc-')[1]) + expect(ordinal).toBeLessThan(600) + expect(ordinal % 5).not.toBe(0) + } + const expectedTagged = await db.execute<{ id: string }>(sql` + SELECT embedding.id FROM embedding JOIN document ON document.id = embedding.document_id + WHERE embedding.knowledge_base_id = ${ids.knowledgeBaseId} AND embedding.enabled + AND document.acl = ARRAY['pub']::text[] AND embedding.tag1 = 'selected' + ORDER BY (embedding.embedding <=> ${JSON.stringify(queryVector)}::vector) + 0, embedding.id LIMIT 15 + `) + const taggedIds = new Set(expectedTagged.map(({ id }) => id)) + const taggedRecall = + tagged.result.data.results.filter((row) => taggedIds.has(row.embeddingId!)).length / + expectedTagged.length + expect(taggedRecall).toBeGreaterThanOrEqual(0.95) + report['workspace-kb.tagged.recall'] = { + neighbors: expectedTagged.length, + recall: taggedRecall, + } + saveReport() + + /** Workspace credentials cannot keep reading a source after its access rewrite begins. */ + await db + .update(knowledgeConnector) + .set({ accessRewritePending: true }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + const denied = await sample('workspace-kb.revoked', () => searchWorkspaceKb()) + expectCompleteVectorSearch(denied.diagnostics) + expect(denied.result.data.results).toEqual([]) + } finally { + await db + .update(knowledgeConnector) + .set({ accessRewritePending: false }) + .where(eq(knowledgeConnector.id, ids.connectorId)) + await db.execute( + sql`UPDATE document SET acl = ARRAY[${originalAcl}] WHERE knowledge_base_id = ${ids.knowledgeBaseId}` + ) + await db.execute(sql`ALTER TABLE document RESET (autovacuum_enabled)`) + await db.execute(sql`ANALYZE document`) + } + }, 180_000) + it('checks live reader access on every search, including after revocation', async () => { await seedSearchReaderFixture(ids) const allowed = await sample('live.allowed', () => search()) diff --git a/apps/sim/lib/knowledge/search/budget.test.ts b/apps/sim/lib/knowledge/search/budget.test.ts index d5e04601737..941fbdbd98e 100644 --- a/apps/sim/lib/knowledge/search/budget.test.ts +++ b/apps/sim/lib/knowledge/search/budget.test.ts @@ -14,7 +14,7 @@ describe('search SQL deadline', () => { now = 101 return ['candidate'] }) - await budget.query('vector.ann', run) + await budget.query('vector.candidate_search', run) await expect(budget.query('vector.exact', run)).rejects.toBeInstanceOf(SearchDeadlineError) expect(run).toHaveBeenCalledTimes(1) }) diff --git a/apps/sim/lib/knowledge/search/diagnostics.ts b/apps/sim/lib/knowledge/search/diagnostics.ts index 9f5f3726575..e308ea56c65 100644 --- a/apps/sim/lib/knowledge/search/diagnostics.ts +++ b/apps/sim/lib/knowledge/search/diagnostics.ts @@ -46,7 +46,6 @@ export type SearchStage = | `${RetrievalLeg}.sql` | 'vector.settings' | 'vector.probe' - | 'vector.ann' | 'vector.rerank' | 'vector.exact' | 'vector.candidate_search' @@ -72,6 +71,7 @@ export interface SearchDiagnosticMetadata { embeddingDimensions?: number vectorRanking?: 'exact' | 'candidate-rerank' vectorCandidateStorage?: 'stored-halfvec' + /** Requested strategy, not an assertion about the physical index selected by PostgreSQL. */ vectorCandidateScan?: 'planned' | 'filtered' vectorBudgetMs?: number vectorCandidateLimit?: number diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index b7a5b9ebabe..dc84ba8ffed 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -316,21 +316,75 @@ describe('getStructuredTagFilters', () => { }) }) -describe('KB block vector retrieval', () => { +describe('workspace-scoped vector retrieval', () => { + const access = { kind: 'workspace' as const, tokens: WORKSPACE_ACCESS_TOKENS } + const getForConnectors = vi.fn() const params: SearchParams = { knowledgeBaseIds: ['kb-small'], topK: 2, - access: { kind: 'workspace', tokens: WORKSPACE_ACCESS_TOKENS }, + access, + accessProvider: { + get: async () => access, + getForConnectors, + getForDocuments: async () => access, + liveSourceConnectorCondition: async () => null, + }, queryVector: { vector: '[0.1,0.2]', dimensions: 1536, model: 'text-embedding-3-small' }, distanceThreshold: 1, } + const probe = Array.from({ length: 400 }, (_, index) => ({ id: `probe-${index}` })) + const candidates = Array.from({ length: 400 }, (_, index) => ({ + id: `candidate-${index}`, + initial_count: 400, + })) + const ranked = [ + { + id: 'near', + documentId: 'near-doc', + connectorId: null, + liveAuthorizationSource: false, + distance: 0.1, + }, + { + id: 'far', + documentId: 'far-doc', + connectorId: null, + liveAuthorizationSource: false, + distance: 0.2, + }, + ] + let probeRows: Array<{ id: string }> + let failSettings: unknown + let failCandidates: unknown - beforeEach(() => resetDbChainMock()) + beforeEach(() => { + resetDbChainMock() + getForConnectors.mockReset() + probeRows = probe + failSettings = undefined + failCandidates = undefined + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query).sql + if (statement.includes('SELECT scoped_chunk.id')) return probeRows + if (statement.includes('hnsw.iterative_scan')) { + if (failSettings) throw failSettings + return [] + } + if (statement.includes('WITH visible_search_documents')) { + if (failCandidates) throw failCandidates + return candidates + } + if (statement.includes('WITH scored_search_candidates')) return ranked + return [] + }) + }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) + const statements = () => dbChainMockFns.execute.mock.calls.map(([query]) => render(query)) + it.each([handleVectorOnlySearch, handleTagAndVectorSearch])( 'does not acquire a connection or start SQL after the KB retrieval deadline', async (search) => { @@ -347,67 +401,233 @@ describe('KB block vector retrieval', () => { expect(budget.timedOut).toBe(true) expect(dbChainMockFns.transaction).not.toHaveBeenCalled() expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).not.toHaveBeenCalled() } ) - it('ranks all candidates in a small KB exactly instead of traversing the shared vector index', async () => { - queueTableRows(schemaMock.embedding, [{ id: 'near' }, { id: 'far' }]) - queueTableRows(schemaMock.embedding, [ - { id: 'far', distance: 0.2 }, - { id: 'near', distance: 0.1 }, - ]) - const rows = await handleVectorOnlySearch(params) - expect(rows.map((row) => row.id)).toEqual(['near', 'far']) - expect(dbChainMockFns.execute).not.toHaveBeenCalled() - expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) - expect(dbChainMockFns.limit).toHaveBeenNthCalledWith(1, 201) + it('ranks an exhausted visible scope exactly and rechecks access before returning content', async () => { + probeRows = ranked + queueTableRows(schemaMock.embedding, ranked) + queueTableRows(schemaMock.embedding, [...ranked].reverse()) + expect((await handleVectorOnlySearch(params)).map((row) => row.id)).toEqual(['near', 'far']) + expect(statements()).toHaveLength(1) + expect(statements()[0].sql).not.toContain('<=>') expect(render(dbChainMockFns.orderBy.mock.calls[0][0]).sql).toContain('+ 0') + for (const [condition] of dbChainMockFns.where.mock.calls) { + expect( + hasMockCondition( + condition, + (node) => + node.type === 'inArray' && + node.column === schemaMock.embedding.id && + Array.isArray(node.values) && + node.values.length === 2 && + node.values.includes('near') + ) + ).toBe(true) + expect(JSON.stringify(condition)).toContain('required_clause') + expect(JSON.stringify(condition)).toContain('aclVerifiedAt') + } + expect(getForConnectors).not.toHaveBeenCalled() + }) + + it('uses compact candidates for a large KB and applies full workspace access before its limit', async () => { + queueTableRows(schemaMock.embedding, [...ranked].reverse()) + expect((await handleVectorOnlySearch(params)).map((row) => row.id)).toEqual(['near', 'far']) + const candidate = statements().find((query) => + query.sql.includes('WITH visible_search_documents') + )! + expect(candidate.sql).toContain('CROSS JOIN LATERAL') + expect(candidate.sql).toContain('LIMIT 1') + const serialized = JSON.stringify(candidate) + expect(serialized).toContain('subvector') + expect(serialized).toContain('required_clause') + expect(serialized).toContain('credential') + expect(serialized).toContain('aclVerifiedAt') + expect(serialized).toContain('accessRewritePending') + expect(serialized).toContain('organizationSearchIntegration') + expect(serialized).toContain(String(schemaMock.embeddingSearch.vector512)) + expect(candidate.params).not.toContain(schemaMock.embedding.embedding) + const rerank = statements().find((query) => + query.sql.includes('WITH scored_search_candidates') + )! + expect(rerank.sql).toContain('MATERIALIZED') + expect(JSON.stringify(rerank)).toContain(String(schemaMock.embedding.embedding)) + expect(JSON.stringify(rerank)).toContain('candidate-399') + expect(JSON.stringify(rerank)).not.toContain('probe-399') + expect(getForConnectors).not.toHaveBeenCalled() + }) + + it('keeps workspace-authorized sources eligible when hydration needs another page', async () => { + const initial = Array.from({ length: 20 }, (_, index) => ({ + ...ranked[0], + id: `initial-${index}`, + distance: index / 100, + connectorId: 'workspace-source', + liveAuthorizationSource: true, + })) + const next = { ...initial[1], id: 'next', distance: 0.3 } + queueTableRows(schemaMock.embedding, initial) + queueTableRows(schemaMock.embedding, [initial[0]]) + queueTableRows(schemaMock.embedding, [next]) + queueTableRows(schemaMock.embedding, [next]) + const rows = await handleVectorOnlySearch({ + ...params, + filters: { documentIds: ['near-doc', 'far-doc'] }, + }) + expect(rows.map((row) => row.id)).toEqual(['initial-0', 'next']) + expect(dbChainMockFns.offset.mock.calls.map(([offset]) => offset)).toEqual([0, 20]) + expect(JSON.stringify(dbChainMockFns.where.mock.calls)).not.toContain('workspace-source') + expect(getForConnectors).not.toHaveBeenCalled() + }) + + it('fills a single result from the same candidate page when its nearest row loses access', async () => { + const execute = dbChainMockFns.execute.getMockImplementation()! + dbChainMockFns.execute.mockImplementation(async (query) => { + const statement = render(query) + if (statement.sql.includes('WITH scored_search_candidates')) { + const limit = Number(statement.params.at(-2)) + const offset = Number(statement.params.at(-1)) + const page = ranked.slice(offset, offset + limit) + queueTableRows( + schemaMock.embedding, + page.filter((row) => row.id !== 'near') + ) + return page + } + return execute(query) + }) + + const rows = await handleVectorOnlySearch({ ...params, topK: 1 }) + + expect(rows.map((row) => row.id)).toEqual(['far']) expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[1][0], - (node) => - node.type === 'inArray' && - node.column === schemaMock.embedding.id && - JSON.stringify(node.values) === JSON.stringify(['near', 'far']) - ) - ).toBe(true) + statements().filter((query) => query.sql.includes('WITH visible_search_documents')) + ).toHaveLength(1) + expect(getForConnectors).not.toHaveBeenCalled() }) - it.each([1, 200, 201])( - 'reports a %i-candidate SQL timeout as partial, not an empty complete result', - async (count) => { - queueTableRows( - schemaMock.embedding, - Array.from({ length: count }, (_, index) => ({ id: `candidate-${index}` })) - ) - dbChainMockFns.orderBy - .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) - .mockRejectedValueOnce(new Error('Statement canceled', { cause: { code: '57014' } })) - const result = await retrieveKnowledgeSearch({ - ...params, - query: 'fixture policy', - searchMode: 'vector', + it('does not turn a broad tag filter into exhaustive full-vector ranking', async () => { + queueTableRows(schemaMock.embedding, probe) + queueTableRows(schemaMock.embedding, ranked) + const rows = await handleTagAndVectorSearch({ + ...params, + structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' }], + }) + expect(rows.map((row) => row.id)).toEqual(['near', 'far']) + expect(Object.keys(dbChainMockFns.select.mock.calls[0][0])).toEqual(['id']) + const candidate = statements().find((query) => + query.sql.includes('WITH visible_search_documents') + )! + expect(JSON.stringify(candidate)).toContain('common') + expect(JSON.stringify(candidate)).toContain(String(schemaMock.embedding.tag1)) + expect(JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)![0])).toContain('common') + expect(getForConnectors).not.toHaveBeenCalled() + }) + + it('ranks all selected KBs together instead of capping how many results one KB can contribute', async () => { + const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5'] + queueTableRows( + schemaMock.embedding, + ranked.map((row) => ({ ...row, knowledgeBaseId: 'kb-1' })) + ) + const rows = await handleVectorOnlySearch({ ...params, knowledgeBaseIds }) + expect(rows.map((row) => row.id)).toEqual(['near', 'far']) + expect(rows.every((row) => row.knowledgeBaseId === 'kb-1')).toBe(true) + const candidateQueries = statements().filter((query) => + query.sql.includes('WITH visible_search_documents') + ) + expect(candidateQueries).toHaveLength(1) + for (const id of knowledgeBaseIds) expect(JSON.stringify(candidateQueries[0])).toContain(id) + expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() + }) + + it.each(['vector.probe', 'vector.candidate_search', 'vector.rerank', 'vector.sql'] as const)( + 'reports a %s timeout as partial, not a complete empty search', + async (failedStage) => { + const query = SearchBudget.prototype.query + vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(function ( + this: SearchBudget, + stage: SearchStage, + run: (executor: SearchExecutor) => PromiseLike + ) { + if (stage === failedStage) + return Promise.reject(new Error('Statement canceled', { cause: { code: '57014' } })) + return query.call(this, stage, run) as Promise }) - expect(result).toEqual({ + expect( + await retrieveKnowledgeSearch({ ...params, query: 'fixture policy', searchMode: 'vector' }) + ).toEqual({ rows: [], retrieval: { status: 'partial', timedOutLegs: ['vector'] }, }) - const usesAnn = dbChainMockFns.execute.mock.calls.some(([statement]) => - render(statement).sql.includes('hnsw.iterative_scan') - ) - expect(usesAnn).toBe(count > 200) } ) - it('does not convert an unexpected ranking error into partial retrieval', async () => { - queueTableRows(schemaMock.embedding, [{ id: 'candidate' }]) - const failure = new Error('Connection lost', { cause: { code: '08006' } }) - dbChainMockFns.orderBy - .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) - .mockRejectedValueOnce(failure) + it('shares the remaining deadline across candidate selection, reranking and hydration', async () => { + vi.spyOn(performance, 'now').mockReturnValue(0) + const query = SearchBudget.prototype.query + vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(async function ( + this: SearchBudget, + stage: SearchStage, + run: (executor: SearchExecutor) => PromiseLike + ) { + const result = await (query.bind(this) as SearchBudget['query'])(stage, run) + if (stage === 'vector.probe') vi.spyOn(performance, 'now').mockReturnValue(30) + if (stage === 'vector.candidate_search') vi.spyOn(performance, 'now').mockReturnValue(60) + if (stage === 'vector.rerank') vi.spyOn(performance, 'now').mockReturnValue(80) + return result + }) + queueTableRows(schemaMock.embedding, ranked) + await handleVectorOnlySearch({ ...params, budget: new SearchBudget('vector', 100) }) + expect( + statements() + .filter((query) => query.sql.includes('statement_timeout')) + .map((query) => query.params[0]) + ).toEqual(['100', '70', '70', '40', '20']) + }) + + it('does not convert an unexpected candidate failure into partial retrieval', async () => { + failCandidates = new Error('Connection lost', { cause: { code: '08006' } }) await expect( retrieveKnowledgeSearch({ ...params, query: 'fixture policy', searchMode: 'vector' }) - ).rejects.toBe(failure) + ).rejects.toBe(failCandidates) + }) + + it('does not treat a missing query object as unsupported scan settings', async () => { + failCandidates = new Error('Query object is missing', { cause: { code: '42704' } }) + await expect(handleVectorOnlySearch(params)).rejects.toBe(failCandidates) + failCandidates = undefined + queueTableRows(schemaMock.embedding, ranked) + await handleVectorOnlySearch(params) + expect(statements().filter((query) => query.sql.includes('hnsw.iterative_scan'))).toHaveLength( + 2 + ) + }) + + it('retries unsupported settings after cooldown without changing the candidate query', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + failSettings = new Error('Failed settings query', { cause: { code: '42704' } }) + queueTableRows(schemaMock.embedding, ranked) + await handleVectorOnlySearch(params) + queueTableRows(schemaMock.embedding, ranked) + await handleVectorOnlySearch(params) + expect(statements().filter((query) => query.sql.includes('hnsw.iterative_scan'))).toHaveLength( + 1 + ) + const queries = statements().filter((query) => + query.sql.includes('WITH visible_search_documents') + ) + expect(queries).toHaveLength(2) + expect(JSON.stringify(queries[0])).toBe(JSON.stringify(queries[1])) + await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1) + failSettings = undefined + queueTableRows(schemaMock.embedding, ranked) + await handleVectorOnlySearch(params) + expect(statements().filter((query) => query.sql.includes('hnsw.iterative_scan'))).toHaveLength( + 2 + ) }) it('reports incomplete retrieval for 18 expired pool waiters without starting their SQL later', async () => { @@ -452,200 +672,6 @@ describe('KB block vector retrieval', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(dbChainMockFns.execute).not.toHaveBeenCalled() }) - - it.each([1, 201])( - 'shares the remaining SQL budget between the probe and %i-candidate ranking', - async (count) => { - vi.spyOn(performance, 'now').mockReturnValue(0) - queueTableRows( - schemaMock.embedding, - Array.from({ length: count }, (_, index) => ({ id: `candidate-${index}` })) - ) - const query = SearchBudget.prototype.query - vi.spyOn(SearchBudget.prototype, 'query').mockImplementation(async function ( - this: SearchBudget, - stage: SearchStage, - run: (executor: SearchExecutor) => PromiseLike - ) { - const runQuery: SearchBudget['query'] = query.bind(this) - const result = await runQuery(stage, run) - if (stage === 'vector.probe') vi.spyOn(performance, 'now').mockReturnValue(30) - return result - }) - await handleVectorOnlySearch({ ...params, budget: new SearchBudget('vector', 100) }) - const timeouts = dbChainMockFns.execute.mock.calls - .map(([statement]) => render(statement)) - .filter((statement) => statement.sql.includes('statement_timeout')) - .map((statement) => statement.params[0]) - expect(timeouts).toEqual(count === 1 ? ['100', '70'] : ['100', '70', '70']) - } - ) -}) - -describe('vector scan settings', () => { - const largeProbe = Array.from({ length: 201 }, (_, index) => ({ id: `probe-${index}` })) - const params: SearchParams = { - knowledgeBaseIds: ['kb-small'], - topK: 2, - access: { kind: 'workspace', tokens: WORKSPACE_ACCESS_TOKENS }, - queryVector: { vector: '[0.1,0.2]', dimensions: 1536, model: 'text-embedding-3-small' }, - distanceThreshold: 0.8, - } - - beforeEach(() => { - resetDbChainMock() - queueTableRows(schemaMock.embedding, largeProbe) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('tunes an overflowing KB scope without limiting ANN to the probe prefix', async () => { - queueTableRows(schemaMock.embedding, [ - { id: 'far', distance: 0.2 }, - { id: 'near', distance: 0.1 }, - ]) - const rows = await handleVectorOnlySearch(params) - expect(rows.map((row) => row.id)).toEqual(['near', 'far']) - expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() - expect(dbChainMockFns.execute).toHaveBeenCalledOnce() - expect(render(dbChainMockFns.execute.mock.calls[0][0])).toEqual({ - sql: "SELECT set_config('hnsw.iterative_scan', 'relaxed_order', true), set_config('hnsw.max_scan_tuples', ?, true)", - params: ['20000'], - }) - expect(dbChainMockFns.execute.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.select.mock.invocationCallOrder[1] - ) - expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[1][0], - (node) => - node.type === 'inArray' && - node.column === schemaMock.embedding.knowledgeBaseId && - Array.isArray(node.values) && - node.values.includes('kb-small') - ) - ).toBe(true) - expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) - expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) - expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[1][0], - (node) => node.type === 'inArray' && node.column === schemaMock.embedding.id - ) - ).toBe(false) - expect(Object.keys(dbChainMockFns.select.mock.calls[1][0])).toEqual(['id', 'distance']) - const ranked = dbChainMockFns.from.mock.calls[2][0] - expect(dbChainMockFns.select.mock.calls[2][0].distance).toBe(ranked.distance) - expect(dbChainMockFns.innerJoin).toHaveBeenCalledWith( - schemaMock.embedding, - expect.objectContaining({ - type: 'eq', - left: schemaMock.embedding.id, - right: ranked.id, - }) - ) - expect(dbChainMockFns.orderBy).toHaveBeenLastCalledWith(ranked.distance) - expect(dbChainMockFns.limit.mock.invocationCallOrder[1]).toBeLessThan( - dbChainMockFns.select.mock.invocationCallOrder[2] - ) - }) - - it('tunes each KB leg and trims their sorted merge', async () => { - const knowledgeBaseIds = ['kb-1', 'kb-2', 'kb-3', 'kb-4', 'kb-5'] - for (let index = 0; index < knowledgeBaseIds.length; index++) { - if (index > 0) queueTableRows(schemaMock.embedding, largeProbe) - queueTableRows(schemaMock.embedding, [{ id: `row-${index}`, distance: (5 - index) / 10 }]) - } - const rows = await handleVectorOnlySearch({ ...params, knowledgeBaseIds }) - expect(rows.map((row) => row.id)).toEqual(['row-4', 'row-3']) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(5) - expect(dbChainMockFns.execute).toHaveBeenCalledTimes(5) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(15) - for (const kbId of knowledgeBaseIds) - expect( - dbChainMockFns.where.mock.calls.some(([condition]) => - hasMockCondition( - condition, - (node) => - node.type === 'eq' && - node.left === schemaMock.embedding.knowledgeBaseId && - node.right === kbId - ) - ) - ).toBe(true) - }) - - it('tunes tag vector queries while keeping tag-only reads outside a vector transaction', async () => { - const filtered: SearchParams = { - ...params, - structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'release' }], - } - queueTableRows(schemaMock.embedding, [ - { id: 'far', distance: 0.2 }, - { id: 'near', distance: 0.1 }, - ]) - expect((await handleTagAndVectorSearch(filtered)).map((row) => row.id)).toEqual(['near', 'far']) - expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() - expect( - hasMockCondition(dbChainMockFns.where.mock.calls[0][0], (node) => { - if (typeof node.toSQL !== 'function') return false - const condition = render(node) - return ( - condition.sql === 'LOWER(?) = LOWER(?)' && - condition.params[0] === schemaMock.embedding.tag1 && - condition.params[1] === 'release' - ) - }) - ).toBe(true) - resetDbChainMock() - await handleTagOnlySearch(filtered) - expect(dbChainMockFns.transaction).not.toHaveBeenCalled() - expect(dbChainMockFns.execute).not.toHaveBeenCalled() - }) - - it('retries unsupported settings after the cooldown without changing the query on fallback', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date(0)) - dbChainMockFns.execute.mockRejectedValueOnce( - new Error('Failed settings query', { cause: { code: '42704' } }) - ) - queueTableRows(schemaMock.embedding, [{ id: 'fallback', distance: 0.1 }]) - expect((await handleVectorOnlySearch(params)).map((row) => row.id)).toEqual(['fallback']) - expect(dbChainMockFns.execute).toHaveBeenCalledOnce() - expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) - await handleVectorOnlySearch(params) - expect(dbChainMockFns.transaction).toHaveBeenCalledOnce() - await vi.advanceTimersByTimeAsync(10 * 60 * 1000 + 1) - queueTableRows(schemaMock.embedding, largeProbe) - await handleVectorOnlySearch(params) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) - }) - - it('propagates an unrelated settings failure without ranking or disabling later tuning', async () => { - const failure = { code: '08006', message: 'Connection lost' } - dbChainMockFns.execute.mockRejectedValueOnce(failure) - await expect(handleVectorOnlySearch(params)).rejects.toBe(failure) - expect(dbChainMockFns.select).toHaveBeenCalledOnce() - expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() - queueTableRows(schemaMock.embedding, largeProbe) - await handleVectorOnlySearch(params) - expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) - }) - - it('does not retry a query 42704 or classify it as unsupported scan settings', async () => { - const failure = { code: '42704', message: 'Query object is missing' } - dbChainMockFns.orderBy - .mockImplementationOnce(dbChainMockFns.orderBy.getMockImplementation()!) - .mockRejectedValueOnce(failure) - await expect(handleVectorOnlySearch(params)).rejects.toBe(failure) - expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) - queueTableRows(schemaMock.embedding, largeProbe) - await handleVectorOnlySearch(params) - expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) - }) }) describe('workspace search filters before ranking', () => { @@ -663,8 +689,8 @@ describe('workspace search filters before ranking', () => { } beforeEach(() => resetDbChainMock()) - function expectScopeOnEveryQuery(skipIdentityProbe = false) { - const queries = dbChainMockFns.where.mock.calls.slice(skipIdentityProbe ? 1 : 0) + function expectScopeOnEveryQuery() { + const queries = dbChainMockFns.where.mock.calls expect(queries.length).toBeGreaterThan(0) for (const [condition] of queries) { expect( @@ -699,15 +725,14 @@ describe('workspace search filters before ranking', () => { it.each([handleVectorOnlySearch, handleTagOnlySearch, handleTagAndVectorSearch])( 'applies the full document scope to vector and tag searches', async (search) => { - const hasIdentityProbe = search !== handleTagOnlySearch - if (hasIdentityProbe) queueTableRows(schemaMock.embedding, [{ id: 'candidate' }]) + queueTableRows(schemaMock.embedding, [{ id: 'candidate' }]) await search({ ...params, structuredFilters: [ { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'launch' }, ], }) - expectScopeOnEveryQuery(hasIdentityProbe) + expectScopeOnEveryQuery() } ) @@ -996,6 +1021,8 @@ describe('live repository authorization follows ranked candidates', () => { '%s ranks identifiers before verification and loads content under the full predicate', async (mode) => { const candidates = [candidate('selected', 'allowed-source')] + if (mode === 'vector' || mode === 'tag-vector') + queueTableRows(schemaMock.embedding, candidates) if (mode === 'keyword') keywordPages.push(candidates) else queueTableRows(schemaMock.embedding, candidates) queueTableRows(schemaMock.embedding, [{ id: 'selected', content: 'verified result' }]) @@ -1020,7 +1047,9 @@ describe('live repository authorization follows ranked candidates', () => { expect(ranking).not.toContain('<=>') expect(ranking).not.toContain('"content"') } else { - expect(Object.keys(dbChainMockFns.select.mock.calls[0][0]).sort()).toEqual( + expect( + Object.keys(dbChainMockFns.select.mock.calls[mode === 'tags' ? 0 : 1][0]).sort() + ).toEqual( [ 'id', 'documentId', @@ -1062,6 +1091,8 @@ describe('live repository authorization follows ranked candidates', () => { async (mode) => { getForConnectors.mockResolvedValue(identity) const candidates = [{ ...candidate('gmail', 'gmail-source'), installationSource: false }] + if (mode === 'vector' || mode === 'tag-vector') + queueTableRows(schemaMock.embedding, candidates) if (mode === 'keyword') keywordPages.push(candidates) else queueTableRows(schemaMock.embedding, candidates) const hydrated = [{ id: 'gmail', content: 'current permitted content' }] @@ -1091,7 +1122,9 @@ describe('live repository authorization follows ranked candidates', () => { const hydration = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)![0]) expect(hydration).toContain('acl') expect(hydration).toContain('knowledgeConnectorMember') - expect(dbChainMockFns.select).toHaveBeenCalledTimes(mode === 'keyword' ? 1 : 2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes( + mode === 'keyword' ? 1 : mode === 'tags' ? 2 : 3 + ) } ) diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index e67a126fa5e..e2b7dc91c17 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -47,16 +47,18 @@ const logger = createLogger('KnowledgeSearchQueries') /** SQLSTATE for an unrecognised configuration parameter — pgvector older than 0.8. */ const UNDEFINED_OBJECT_SQLSTATE = '42704' -/** Tuples a relaxed-order scan may visit before giving up on filling the limit. */ -const HNSW_MAX_SCAN_TUPLES = '20000' -/** Stop a permission-starved graph walk early enough to scan the filtered projection instead. */ +/** Bound candidate pages retained while live permissions are checked. */ +const MAX_AUTHORIZED_SEARCH_CANDIDATES = 20_000 +/** + * Stop a permission-starved graph walk early enough to scan the filtered projection instead. + * This approximate iterative-visit threshold excludes pgvector's initial scan; it is not a row limit. + */ const CANDIDATE_HNSW_MAX_SCAN_TUPLES = '1000' const CANDIDATE_HNSW_EF_SEARCH = '1000' const CANDIDATE_HNSW_SCAN_MEM_MULTIPLIER = '2' const MIN_VECTOR_RERANK_CANDIDATES = 400 const MAX_VECTOR_RERANK_CANDIDATES = 1600 -const VECTOR_RERANK_OVERSAMPLING = 8 -const MAX_EXACT_KB_VECTOR_CANDIDATES = 200 +const VECTOR_RERANK_OVERSAMPLING = 32 /** How long to stop trying the iterative-scan settings after the server rejected them. */ const HNSW_SETTINGS_UNSUPPORTED_RETRY_MS = 10 * 60 * 1000 @@ -71,10 +73,9 @@ let hnswSettingsUnsupportedUntil = 0 */ async function withVectorScanSettings( run: (executor: SearchExecutor) => Promise, - budget?: SearchBudget, - ranking: 'cosine' | 'candidate' = 'cosine' + budget?: SearchBudget ): Promise { - const stage = ranking === 'candidate' ? 'vector.candidate_search' : 'vector.ann' + const stage = 'vector.candidate_search' const untuned = () => runSearchQuery(budget, stage, run) if (Date.now() < hnswSettingsUnsupportedUntil) return untuned() const acquireStarted = performance.now() @@ -86,9 +87,7 @@ async function withVectorScanSettings( applyingSettings = true await measureSearchStage('vector.settings', () => tx.execute( - ranking === 'candidate' - ? sql`SELECT set_config('hnsw.iterative_scan', 'relaxed_order', true), set_config('hnsw.max_scan_tuples', ${CANDIDATE_HNSW_MAX_SCAN_TUPLES}, true), set_config('hnsw.ef_search', ${CANDIDATE_HNSW_EF_SEARCH}, true), set_config('hnsw.scan_mem_multiplier', ${CANDIDATE_HNSW_SCAN_MEM_MULTIPLIER}, true)` - : sql`SELECT set_config('hnsw.iterative_scan', 'relaxed_order', true), set_config('hnsw.max_scan_tuples', ${HNSW_MAX_SCAN_TUPLES}, true)` + sql`SELECT set_config('hnsw.iterative_scan', 'relaxed_order', true), set_config('hnsw.max_scan_tuples', ${CANDIDATE_HNSW_MAX_SCAN_TUPLES}, true), set_config('hnsw.ef_search', ${CANDIDATE_HNSW_EF_SEARCH}, true), set_config('hnsw.scan_mem_multiplier', ${CANDIDATE_HNSW_SCAN_MEM_MULTIPLIER}, true)` ) ) applyingSettings = false @@ -512,17 +511,18 @@ const SEARCH_READ_CANDIDATE_FIELDS = { )`, } -const LIVE_SEARCH_PAGE_SIZE = 200 -const LIVE_SEARCH_BUDGET_MS = 8000 +const AUTHORIZED_SEARCH_PAGE_SIZE = 200 +const AUTHORIZED_SEARCH_BUDGET_MS = 8000 /** * Verification follows ranked candidates, never the organization's source order. Denied * sources are excluded on refill, so many matches from one revoked source cannot - * consume every result slot. The existing vector tuple budget also bounds candidate work. + * consume every result slot. Candidate pages and the shared deadline bound authorization work. */ async function selectAuthorizedSearchResults(input: { leg: 'vector' | 'keyword' | 'tags' - accessProvider: KnowledgeAccessProvider + access: KnowledgeAccessScope + accessProvider?: KnowledgeAccessProvider filters?: WorkspaceSearchFilters signal?: AbortSignal budget?: SearchBudget @@ -535,8 +535,8 @@ async function selectAuthorizedSearchResults(input: { compareResults?: (a: SearchResult, b: SearchResult) => number hydrate: (ids: string[], access: KnowledgeAccessScope) => Promise }): Promise { - const deadline = Date.now() + LIVE_SEARCH_BUDGET_MS - const pageSize = Math.min(LIVE_SEARCH_PAGE_SIZE, Math.max(input.topK, 20)) + const deadline = Date.now() + AUTHORIZED_SEARCH_BUDGET_MS + const pageSize = Math.min(AUTHORIZED_SEARCH_PAGE_SIZE, Math.max(input.topK, 20)) const results = new Map() const excludedSources = new Set() const considered = new Set() @@ -545,7 +545,7 @@ async function selectAuthorizedSearchResults(input: { try { while ( results.size < input.topK && - scanned < Number(HNSW_MAX_SCAN_TUPLES) && + scanned < MAX_AUTHORIZED_SEARCH_CANDIDATES && (input.budget !== undefined || Date.now() < deadline) ) { input.signal?.throwIfAborted() @@ -576,7 +576,9 @@ async function selectAuthorizedSearchResults(input: { ), ] const access = await measureSearchStage(`${input.leg}.authorization`, () => - input.accessProvider.getForConnectors(connectorIds, input.signal) + input.accessProvider + ? input.accessProvider.getForConnectors(connectorIds, input.signal) + : input.access ) input.signal?.throwIfAborted() const grantedSources = new Set( @@ -590,6 +592,7 @@ async function selectAuthorizedSearchResults(input: { const excludedBefore = excludedSources.size for (const candidate of candidates) { if ( + input.accessProvider && candidate.liveAuthorizationSource && candidate.connectorId && !grantedSources.has(candidate.connectorId) @@ -686,6 +689,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise { - const { knowledgeBaseIds, topK, queryVector, distanceThreshold, access } = params - + const { queryVector, distanceThreshold } = params if (!queryVector || !distanceThreshold) { throw new Error('Query vector and distance threshold are required for vector-only search') } - - const strategy = getQueryStrategy(knowledgeBaseIds.length, topK) - - const distance = embeddingDistance(queryVector.dimensions, queryVector.vector) - if (params.accessProvider && access.kind === 'user') { - return selectLiveVectorResults(params, params.accessProvider, distance, [ - sql`${distance} < ${distanceThreshold}`, - ]) - } - /** - * A relaxed-order iterative scan may hand rows back slightly out of distance - * order, so both paths re-sort in memory before trimming to `topK`. - */ - if (strategy.useParallel) { - const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5 - const allResults: SearchResult[] = [] - /** Keep one active KB leg per request so multi-base searches cannot monopolize the pool. */ - for (const kbId of knowledgeBaseIds) { - allResults.push( - ...(await selectScopedVectorResults( - params, - distance, - eq(embedding.knowledgeBaseId, kbId), - parallelLimit - )) - ) - if (params.budget?.timedOut) break - } - return allResults.sort((a, b) => a.distance - b.distance).slice(0, topK) - } - const rows = await selectScopedVectorResults( - params, - distance, - inArray(embedding.knowledgeBaseId, knowledgeBaseIds), - topK - ) - return rows.sort((a, b) => a.distance - b.distance) -} - -/** - * KB runs without a human subject still need bounded small-scope ranking. Probe only chunk - * identities, then reapply every access and visibility predicate before ranking and hydration. - * An overflowing probe selects ANN over the whole scope, never a truncated candidate prefix. - */ -async function selectScopedVectorResults( - params: SearchParams, - distance: SQL, - kbScope: SQL | undefined, - limit: number, - tagConditions: (SQL | undefined)[] = [] -): Promise { - try { - const probe = await runSearchQuery(params.budget, 'vector.probe', (executor) => - executor - .select({ id: embedding.id }) - .from(embedding) - .where(and(kbScope, eq(embedding.enabled, true), ...tagConditions)) - .limit(MAX_EXACT_KB_VECTOR_CANDIDATES + 1) - ) - if (probe.length === 0) return [] - const conditions = [ - kbScope, - ...getVisibilityConditions(params.access, params.filters), - ...tagConditions, - sql`${distance} < ${params.distanceThreshold}`, - ] - if (probe.length <= MAX_EXACT_KB_VECTOR_CANDIDATES) { - annotateSearchDiagnostics({ vectorRanking: 'exact' }) - return await runSearchQuery(params.budget, 'vector.exact', (executor) => - selectRankedVectorResults( - executor, - distance, - [ - ...conditions, - inArray( - embedding.id, - probe.map((candidate) => candidate.id) - ), - ], - limit, - true - ) - ) - } - return await withVectorScanSettings( - (executor) => selectRankedVectorResults(executor, distance, conditions, limit), - params.budget - ) - } catch (error) { - if (!params.budget?.isTimeout(error)) throw error - return [] - } + return selectVectorResults(params) } /** @@ -870,14 +782,27 @@ async function selectScopedVectorResults( * An underfilled index scan expands to a filtered scan within the same statement snapshot. * Live source authorization and content hydration still run after candidate ranking. */ -async function selectLiveVectorResults( - params: SearchParams, - accessProvider: KnowledgeAccessProvider, - distance: SQL, - filters: (SQL | undefined)[] -): Promise { - const conditions = [inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds), ...filters] +async function selectVectorResults(params: SearchParams): Promise { const queryVector = params.queryVector! + const distance = embeddingDistance(queryVector.dimensions, queryVector.vector) + const tagConditions = getStructuredTagFilters(params.structuredFilters ?? [], embedding) + const conditions = [ + inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds), + ...tagConditions, + sql`${distance} < ${params.distanceThreshold!}`, + ] + /** Tags live on chunks; apply them before the candidate limit without fetching full vectors. */ + const candidateTagCondition = tagConditions.length + ? sql`EXISTS ( + SELECT 1 FROM ${embedding} + WHERE ${and(eq(embedding.id, embeddingSearch.id), ...tagConditions)} + )` + : undefined + const accessProvider = params.access.kind === 'user' ? params.accessProvider : undefined + /** Only live-verified readers may defer source authorization until after candidate ranking. */ + const candidateAccess = accessProvider + ? knowledgeMetadataCandidateAccessCondition(params.access) + : knowledgeAccessCondition(params.access) const candidateDistance = embeddingCandidateDistance( queryVector.dimensions, queryVector.vector, @@ -887,8 +812,9 @@ async function selectLiveVectorResults( MAX_VECTOR_RERANK_CANDIDATES, Math.max(MIN_VECTOR_RERANK_CANDIDATES, params.topK * VECTOR_RERANK_OVERSAMPLING) ) - const rows = await selectAuthorizedSearchResults({ + return selectAuthorizedSearchResults({ leg: 'vector', + access: params.access, accessProvider, filters: params.filters, signal: params.signal, @@ -897,23 +823,15 @@ async function selectLiveVectorResults( compareResults: (a, b) => a.distance - b.distance, selectPage: async (limit, offset, excludedSources) => { const visibility = [ - ...getVisibilityConditions( - params.access, - params.filters, - knowledgeMetadataCandidateAccessCondition(params.access) - ), + ...getVisibilityConditions(params.access, params.filters, candidateAccess), excludeSearchSources(excludedSources), ] const candidateDocumentVisibility = [ inArray(document.knowledgeBaseId, params.knowledgeBaseIds), - ...getDocumentVisibilityConditions( - params.access, - params.filters, - knowledgeMetadataCandidateAccessCondition(params.access) - ), + ...getDocumentVisibilityConditions(params.access, params.filters, candidateAccess), excludeSearchSources(excludedSources), ] - /** Explicitly filtered scopes use exact ordering instead of HNSW traversal. */ + /** Exhausted scopes rank exactly; explicit document IDs retain exhaustive passage ordering. */ const exactPage = async (candidateIds?: string[]) => { annotateSearchDiagnostics({ vectorRanking: 'exact' }) const candidates = await runSearchQuery(params.budget, 'vector.exact', (executor) => @@ -934,16 +852,27 @@ async function selectLiveVectorResults( ) return { candidates, nextOffset: offset + candidates.length } } - if (params.filters?.documentIds?.length || params.structuredFilters?.length) { - return exactPage() - } + if (params.filters?.documentIds?.length) return exactPage() /** * Enumerate bounded chunk identities from visible documents. The lateral limit keeps * the probe on document-indexed lookups instead of hashing the entire vector projection. * An exhausted probe fits in the rerank pool and needs only one exact ranking pass. */ const probe = await runSearchQuery(params.budget, 'vector.probe', (executor) => - executor.execute<{ id: string }>(sql` + tagConditions.length + ? executor + .select({ id: embedding.id }) + .from(embedding) + .innerJoin(document, eq(document.id, embedding.documentId)) + .where( + and( + inArray(embedding.knowledgeBaseId, params.knowledgeBaseIds), + ...visibility, + ...tagConditions + ) + ) + .limit(candidateLimit) + : executor.execute<{ id: string }>(sql` SELECT scoped_chunk.id FROM ${document} CROSS JOIN LATERAL ( SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} @@ -988,7 +917,7 @@ async function selectLiveVectorResults( SELECT ${embeddingSearch.id} AS id FROM ${embeddingSearch} CROSS JOIN LATERAL ( SELECT 1 FROM ${document} - WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility)} + WHERE ${and(eq(document.id, embeddingSearch.documentId), ...candidateDocumentVisibility, candidateTagCondition)} LIMIT 1 ) AS visible WHERE ${and( @@ -1001,7 +930,8 @@ async function selectLiveVectorResults( ${candidateDistance} AS distance FROM ${embeddingSearch} WHERE ${and( inArray(embeddingSearch.knowledgeBaseId, params.knowledgeBaseIds), - eq(embeddingSearch.enabled, true) + eq(embeddingSearch.enabled, true), + candidateTagCondition )} AND (SELECT count(*) FROM initial_candidates) < ${candidateLimit} ), candidates AS ( @@ -1016,8 +946,7 @@ async function selectLiveVectorResults( ) ) SELECT id, (SELECT count(*)::int FROM initial_candidates) AS initial_count FROM candidates `), - params.budget, - 'candidate' + params.budget ) const initialCount = identities[0]?.initial_count ?? 0 annotateSearchDiagnostics({ @@ -1062,37 +991,6 @@ async function selectLiveVectorResults( params.budget ), }) - return rows -} - -/** - * Sort only chunk identities and distances before loading result content. Carrying - * full chunk rows through the vector sort can spill to disk. The bounded subquery - * keeps every visibility predicate before the limit; hydration joins the same - * statement snapshot by primary key, without another distance calculation. - */ -function selectRankedVectorResults( - executor: SearchExecutor, - distance: SQL, - conditions: (SQL | undefined)[], - limit: number, - exact = false -) { - const ranked = executor - .select({ id: embedding.id, distance: distance.as('distance') }) - .from(embedding) - .innerJoin(document, eq(embedding.documentId, document.id)) - .where(and(...conditions)) - .orderBy(exact ? sql`(${distance}) + 0` : distance) - .limit(limit) - .as('ranked_embeddings') - - return executor - .select(getSearchResultFields(ranked.distance)) - .from(ranked) - .innerJoin(embedding, eq(embedding.id, ranked.id)) - .innerJoin(document, eq(document.id, embedding.documentId)) - .orderBy(ranked.distance) } export interface KeywordSearchParams { @@ -1154,6 +1052,7 @@ export async function executeKeywordSearch(params: KeywordSearchParams): Promise /** Keep readable identities and rank scalars separate so sorts never carry full text-search vectors. */ return selectAuthorizedSearchResults({ leg: 'keyword', + access: params.access, accessProvider: params.accessProvider, filters: params.filters, signal: params.signal, @@ -1352,32 +1251,14 @@ export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number } export async function handleTagAndVectorSearch(params: SearchParams): Promise { - const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold, access } = - params - + const { structuredFilters, queryVector, distanceThreshold } = params if (!structuredFilters || structuredFilters.length === 0) { throw new Error('Tag filters are required for tag and vector search') } if (!queryVector || !distanceThreshold) { throw new Error('Query vector and distance threshold are required for tag and vector search') } - - const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding) - const distance = embeddingDistance(queryVector.dimensions, queryVector.vector) - if (params.accessProvider && access.kind === 'user') { - return selectLiveVectorResults(params, params.accessProvider, distance, [ - ...tagFilterConditions, - sql`${distance} < ${distanceThreshold}`, - ]) - } - const rows = await selectScopedVectorResults( - params, - distance, - inArray(embedding.knowledgeBaseId, knowledgeBaseIds), - topK, - tagFilterConditions - ) - return rows.sort((a, b) => a.distance - b.distance) + return selectVectorResults(params) } /** From 1facb1225318c240bb642d9b133878be2c6a6309 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 17 Sep 2026 16:13:02 -0700 Subject: [PATCH 4/8] fix(landing): repair public links and streamline landing previews (#7949) * fix(landing): repair public links and streamline landing previews * fix(landing): remove obsolete tracking and preserve preview interactions * fix(landing): remove Sakana from featured footer providers --- apps/docs/app/robots.txt/route.ts | 1 - apps/docs/components/footer/footer.tsx | 5 +- .../core-feature-card/core-feature-card.tsx | 1 + .../features-rail/features-rail.test.tsx | 31 +- .../features-rail/features-rail.tsx | 23 +- .../(landing)/components/footer/footer.tsx | 4 +- .../production-workflow-stage.tsx | 92 +++--- .../nav-menu-chip/nav-menu-chip.test.tsx | 18 ++ .../nav-menu-chip/nav-menu-chip.tsx | 14 +- .../site-structured-data.tsx | 2 +- .../cookie-policy/cookie-policy-content.tsx | 14 +- .../interactive-library-folder.tsx | 5 +- .../hubspot-page-view-tracker.test.tsx | 50 --- .../(landing)/hubspot-page-view-tracker.tsx | 31 -- .../(landing)/landing-consent-tracking.tsx | 11 +- apps/sim/app/llms-full.txt/route.ts | 2 +- apps/sim/app/sitemap.test.ts | 54 ++++ apps/sim/app/sitemap.ts | 15 +- .../resource-tabs/resource-tabs.tsx | 2 +- apps/sim/lib/consent/scripts.test.ts | 14 +- apps/sim/lib/consent/scripts.ts | 18 -- apps/sim/lib/content/seo.ts | 2 +- apps/sim/lib/core/security/csp.ts | 10 - apps/sim/next.config.ts | 2 +- .../tab-strip/tab-strip.dom.test.tsx | 80 +++++ .../src/components/tab-strip/tab-strip.tsx | 52 +++- packages/workflow-renderer/package.json | 8 + .../src/block-tile-view.test.tsx | 7 + packages/workflow-renderer/src/index.ts | 22 +- .../src/subflow/subflow-node-view.tsx | 2 +- .../workflow-block/workflow-block-view.tsx | 284 +----------------- .../workflow-renderer/src/workflow-type.tsx | 273 +++++++++++++++++ 32 files changed, 649 insertions(+), 500 deletions(-) delete mode 100644 apps/sim/app/(landing)/hubspot-page-view-tracker.test.tsx delete mode 100644 apps/sim/app/(landing)/hubspot-page-view-tracker.tsx create mode 100644 apps/sim/app/sitemap.test.ts create mode 100644 packages/workflow-renderer/src/workflow-type.tsx diff --git a/apps/docs/app/robots.txt/route.ts b/apps/docs/app/robots.txt/route.ts index c9f62b347f8..205397a3b1c 100644 --- a/apps/docs/app/robots.txt/route.ts +++ b/apps/docs/app/robots.txt/route.ts @@ -10,7 +10,6 @@ export async function GET() { User-agent: * Disallow: /.next/ Disallow: /api/internal/ -Disallow: /_next/static/ Disallow: /admin/ Allow: / Allow: /llms.txt diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index 342f0b733ab..8ed44b21d40 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -41,7 +41,7 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Contact', href: `${SIM_SITE_URL}/contact`, external: true }, ] -/** Top model providers — mirrors the landing footer's top 8 catalog providers. */ +/** Top model providers — mirrors the landing footer's top 7 catalog providers. */ const MODEL_LINKS: FooterItem[] = [ { label: 'All Models', href: `${SIM_SITE_URL}/models`, external: true }, { label: 'OpenAI', href: `${SIM_SITE_URL}/models/openai`, external: true }, @@ -51,7 +51,6 @@ const MODEL_LINKS: FooterItem[] = [ { label: 'xAI', href: `${SIM_SITE_URL}/models/xai`, external: true }, { label: 'Cerebras', href: `${SIM_SITE_URL}/models/cerebras`, external: true }, { label: 'Groq', href: `${SIM_SITE_URL}/models/groq`, external: true }, - { label: 'Sakana AI', href: `${SIM_SITE_URL}/models/sakana`, external: true }, ] const BLOCK_LINKS: FooterItem[] = [ @@ -84,7 +83,7 @@ const SOCIAL_LINKS: FooterItem[] = [ { label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true }, { label: 'LinkedIn', - href: 'https://www.linkedin.com/company/simstudioai/', + href: 'https://www.linkedin.com/company/simdotai/', external: true, }, { diff --git a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx index f5a0bacbedc..f28ee54d6de 100644 --- a/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx +++ b/apps/sim/app/(landing)/components/features/components/core-feature-card/core-feature-card.tsx @@ -48,6 +48,7 @@ export function CoreFeatureCard({ const graphic = (
{ root = null host?.remove() host = null + vi.unstubAllGlobals() }) function mount(strict = false): HTMLElement { @@ -106,6 +107,34 @@ describe('foldScrollLeft', () => { }) describe('FeaturesRail', () => { + it('waits until the rail approaches the viewport before adding the loop copies', () => { + let notify: IntersectionObserverCallback | undefined + const disconnect = vi.fn() + const observe = vi.fn() + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + notify = callback + } + observe = observe + disconnect = disconnect + } + ) + const rail = mount() + expect(observe).toHaveBeenCalledWith(rail) + expect(rail.children).toHaveLength(3) + const observer = {} as IntersectionObserver + act(() => notify?.([{ isIntersecting: false } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(3) + act(() => notify?.([{ isIntersecting: true } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(9) + expect(rail.scrollLeft).toBe(SET) + expect(disconnect).toHaveBeenCalledOnce() + act(() => notify?.([{ isIntersecting: false } as IntersectionObserverEntry], observer)) + expect(rail.children).toHaveLength(9) + }) + it('server-renders the finite rail once, with the scroll chrome', () => { const html = renderToStaticMarkup( {cards()} diff --git a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx index c2bcf2058dc..93ef0f0bd67 100644 --- a/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx +++ b/apps/sim/app/(landing)/components/features/components/features-rail/features-rail.tsx @@ -86,8 +86,8 @@ interface FeaturesRailProps { /** Accessible name of the scrolling region. */ label: string /** - * The cards, in order. Each becomes one slot; once JS runs the whole set is - * cloned on both sides so the rail loops. + * The cards, in order. Each becomes one slot; as the rail approaches the + * viewport the whole set is cloned on both sides so the rail loops. */ children: ReactNode } @@ -96,7 +96,7 @@ interface FeaturesRailProps { * The homepage product rail: native horizontal scrolling that never ends. * * The server renders the set once, so the HTML - and any visit without JS - is - * the plain finite rail with the first card under the heading. After hydration + * the plain finite rail with the first card under the heading. Near the viewport * the set is cloned once on each side, the scroll position jumps one set width * before paint so nothing visibly moves (folded, so Strict Mode's second run of * the effect lands on the same spot), and a passive scroll listener folds the @@ -124,7 +124,22 @@ export function FeaturesRail({ label, children }: FeaturesRailProps) { const cards = Children.toArray(children) useEffect(() => { - setLooping(true) + const rail = railRef.current + if (!rail) return + if (typeof IntersectionObserver === 'undefined') { + setLooping(true) + return + } + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + setLooping(true) + observer.disconnect() + }, + { rootMargin: '600px' } + ) + observer.observe(rail) + return () => observer.disconnect() }, []) useLayoutEffect(() => { diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 44be284c0d4..4d8d7fae2bc 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -87,7 +87,7 @@ const RESOURCES_LINKS: FooterItem[] = [ /** Top model providers, sourced from the catalog so labels/hrefs never drift. */ const MODEL_LINKS: FooterItem[] = [ { label: 'All Models', href: '/models' }, - ...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map((provider) => ({ + ...MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 7).map((provider) => ({ label: provider.name, href: provider.href, })), @@ -119,7 +119,7 @@ const SOCIAL_LINKS: FooterItem[] = [ { label: 'X (Twitter)', href: 'https://x.com/simdotai', external: true }, { label: 'LinkedIn', - href: 'https://www.linkedin.com/company/simstudioai/', + href: 'https://www.linkedin.com/company/simdotai/', external: true, }, { diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx index b142adc61ce..54e972887d8 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/production-workflow-stage.tsx @@ -75,20 +75,20 @@ const EMPTY_IDS: ReadonlySet = new Set() const ACTION_BUTTON_STYLES = [ 'size-[24px] rounded-md p-0', 'border-none bg-transparent text-[var(--text-icon)]', - 'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]', - 'dark:hover-hover:bg-[var(--surface-4)]', - 'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]', + 'transition-[background-color,color,opacity,transform] duration-150', 'group-data-[node-selected]:text-[var(--surface-2)]', - 'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]', - 'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]', ].join(' ') const FIRST_ACTION_STYLES = "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&>svg]:translate-x-[8px] [&>svg]:translate-y-px" +/** A 24px target even at MIN_ZOOM, extending above/left of the unchanged 40px painted slot. */ +const RUN_ACTION_HIT_STYLES = + 'group/run relative -ml-[14px] size-[54px] shrink-0 border-none bg-transparent! p-0' + /** The running run slot: graphite fill, inverse glyph - the editor's own treatment. */ const RUNNING_RUN_STYLES = - '!bg-[var(--text-secondary)] !text-[var(--text-inverse)] hover-hover:!bg-[var(--white)] hover-hover:!text-[var(--surface-inverted)]' + '!bg-[var(--text-secondary)] !text-[var(--text-inverse)] group-hover-hover/run:!bg-[var(--white)] group-hover-hover/run:!text-[var(--surface-inverted)]' /** A bystander card's actions dim mid-run; the run/stop slot keeps its ordinary chrome. */ const BYSTANDER_ACTION_STYLES = '!bg-transparent !opacity-25 hover-hover:!bg-transparent dark:hover-hover:!bg-transparent' @@ -227,7 +227,7 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev return (
{sweeping && ( @@ -250,35 +250,42 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev )} - + @@ -288,23 +295,22 @@ function PreviewActionBar({ block, running, workflowRunning, onRunToggle }: Prev {inertActions.map(({ label, Icon }) => ( - + + + {!workflowRunning && {label}} diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx index c2523b051cb..37ea2107d22 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.test.tsx @@ -27,6 +27,13 @@ vi.mock('next/link', () => ({ ), })) +vi.mock('next/dynamic', () => ({ + default: + () => + ({ item }: { item: NavMenuItemData }) => ( + {item.preview.kind} + ), +})) vi.mock('@/app/(landing)/components/chevron-arrow', () => ({ ChevronArrow: () => null, })) @@ -104,6 +111,17 @@ function expectSelected(href: string, kind: string) { } describe('NavMenuCluster feature selection', () => { + it('mounts the preview on first opening and preserves it during the exit transition', () => { + expect(host.querySelector('output')).toBeNull() + hover(element('#nav-platform-menu-trigger')) + expect(host.querySelector('output')).not.toBeNull() + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + expect(element('#primary-navigation-mega-menu').getAttribute('aria-hidden')).toBe('true') + expect(host.querySelector('output')).not.toBeNull() + }) + it('prefetches destinations only while their menu is open', () => { const overview = element('a[href="/platform"]') const customers = element('#nav-customers-menu a[href="/customers/rivian"]') diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx index 80afd4e6fc2..ce6f699f547 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react' import { ChipChevronDown, chipContentLabelClass, chipVariants, cn } from '@sim/emcn' +import dynamic from 'next/dynamic' import { flushSync } from 'react-dom' import { HOME_INSET, @@ -11,11 +12,18 @@ import { import { NavMenuCard } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-card' import { NavMenuItem } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-item' import { NavMenuLogoMarquee } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-logo-marquee' -import { NavMenuPreview } from '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/nav-menu-preview' import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-menu-chip/types' import { NAVBAR_GLASS_SURFACE } from '@/app/(landing)/components/navbar/components/navbar-shell' import { useNavbarMenu } from '@/app/(landing)/components/navbar/hooks/use-navbar-menu' +const NavMenuPreview = dynamic( + () => + import( + '@/app/(landing)/components/navbar/components/nav-menu-chip/components/nav-menu-preview/nav-menu-preview' + ).then((module) => module.NavMenuPreview), + { loading: () =>
} +) + interface NavMenuClusterProps { /** Non-empty group of mega-menus that share one stable panel. */ menus: readonly [NavMenu, ...NavMenu[]] @@ -68,6 +76,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { () => menus.find((menu) => !isFloating(menu)) ?? menus[0] ) const [activeItem, setActiveItem] = useState(surfaceMenu.sections[0].items[0]) + const [previewMounted, setPreviewMounted] = useState(false) useEffect(() => { if (!open) return @@ -86,6 +95,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { const activateMenu = (menu: NavMenu) => { setActiveMenu(menu) if (!isFloating(menu)) { + setPreviewMounted(true) setSurfaceMenu(menu) setActiveItem(menu.sections[0].items[0]) } @@ -270,7 +280,7 @@ export function NavMenuCluster({ menus, modelsPreview }: NavMenuClusterProps) { ))}
- + {previewMounted && }
diff --git a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx index a4b8fa24914..591920d098e 100644 --- a/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx +++ b/apps/sim/app/(landing)/components/site-structured-data/site-structured-data.tsx @@ -36,7 +36,7 @@ const SITE_JSON_LD = { sameAs: [ 'https://x.com/simdotai', 'https://github.com/simstudioai/sim', - 'https://www.linkedin.com/company/simstudioai/', + 'https://www.linkedin.com/company/simdotai/', 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA', ], contactPoint: [ diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index 4da00f38e27..563b7b7f9f3 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -44,7 +44,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', - lastUpdated: 'September 3, 2026', + lastUpdated: 'September 17, 2026', intro: [ { kind: 'paragraph', @@ -165,7 +165,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { [ '__cf_bm', 'Cloudflare', - 'Bot-management check on requests to providers we load, such as HubSpot and X.', + 'Bot-management check on requests to providers we load, such as X.', '30 minutes', ], ]), @@ -177,10 +177,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { 'Holds the session state for a specific Analytics property.', '13 months', ], - ['__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months'], - ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], - ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], - ['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'], [ 'ph_*_posthog', 'PostHog', @@ -269,9 +265,8 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { Google Analytics , Google Ads,{' '} - X (Twitter),{' '} - HubSpot, and{' '} - PostHog. + X (Twitter), + and PostHog. ), }, @@ -292,7 +287,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { The providers currently in use are{' '} Google{' '} (Analytics and Ads),{' '} - HubSpot,{' '} X (Twitter),{' '} Ahrefs,{' '} PostHog, and{' '} diff --git a/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx b/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx index ad1d78fd5b5..cff20bc4ebd 100644 --- a/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx +++ b/apps/sim/app/(landing)/files/components/feature-graphics/interactive-library-folder.tsx @@ -84,7 +84,7 @@ export function InteractiveLibraryFolder({