1+ import { ErrorCode , McpError } from '@modelcontextprotocol/sdk/types.js'
12import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
23import { safeCompare } from '@sim/security/compare'
34import { hmacSha256Hex } from '@sim/security/hmac'
@@ -25,6 +26,7 @@ import {
2526} from '@/lib/core/resource-scope'
2627import { createPinnedConnectionPool } from '@/lib/core/security/input-validation.server'
2728import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
29+ import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors'
2830import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/availability'
2931import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
3032import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts'
@@ -33,12 +35,14 @@ import { measureSearchStage } from '@/lib/knowledge/search/diagnostics'
3335import { RRF_K } from '@/lib/knowledge/search/recency'
3436import { matchPassage } from '@/lib/knowledge/search/snippet'
3537import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url'
38+ import { connectorDisplayName } from '@/lib/sim-search/connectors'
3639import {
3740 type LiveAccountSession ,
3841 openLiveAccountSession ,
3942} from '@/lib/sim-search/live/account-session'
4043import {
4144 listLiveAccounts ,
45+ type ResolvedLiveAccount ,
4246 resolveListedLiveAccount ,
4347 resolveLiveAccount ,
4448} from '@/lib/sim-search/live/accounts'
@@ -51,10 +55,12 @@ import {
5155 withImpliedListingBound ,
5256} from '@/lib/sim-search/live/dates'
5357import { NativeSearchError } from '@/lib/sim-search/live/http'
58+ import { isManagedSearchMcpProvider } from '@/lib/sim-search/live/managed-mcp-config'
5459import { joinMessages } from '@/lib/sim-search/live/pages'
5560import { loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store'
5661import { LIVE_SEARCH_PROVIDER_IDS } from '@/lib/sim-search/live/provider-catalog'
5762import { liveSearchGuidance } from '@/lib/sim-search/live/providers'
63+ import { LiveReadError } from '@/lib/sim-search/live/read-error'
5864import type { LiveAccount , NativeDocument } from '@/lib/sim-search/live/types'
5965import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
6066import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -742,6 +748,53 @@ export type LiveReadInput = ResourceOwner & {
742748 signal ?: AbortSignal
743749}
744750
751+ /** Milliseconds one document read may spend on provider calls, after account resolution. */
752+ const READ_DEADLINE_MS = 15_000
753+
754+ function liveProviderName ( provider : string ) : string {
755+ return isManagedSearchMcpProvider ( provider )
756+ ? MANAGED_MCP_CONNECTORS [ provider ] . name
757+ : connectorDisplayName ( provider )
758+ }
759+
760+ /**
761+ * Classifies a provider failure during a read so the caller can act on it: a missing or
762+ * non-readable document, a grant to reconnect, or a transient failure worth retrying.
763+ * Classified and unrecognized errors pass through unchanged.
764+ */
765+ function liveReadFailure ( error : unknown , provider : string , deadline ?: AbortSignal ) : unknown {
766+ if ( error instanceof OrchestrationError ) return error
767+ const name = liveProviderName ( provider )
768+ if ( error instanceof NativeSearchError ) {
769+ if ( error . status === 'reconnect' )
770+ return new OrchestrationError (
771+ 'unauthorized' ,
772+ `Reconnect ${ name } to read this document. ${ error . message } `
773+ )
774+ if ( error . status === 'rate_limited' )
775+ return new LiveReadError ( error . message , true , error . retryAfterSeconds )
776+ if ( error . status === 'timeout' )
777+ return new LiveReadError ( `${ name } timed out reading this document. Try again.` , true )
778+ if ( error . httpStatus === 404 || error . httpStatus === 410 )
779+ return new OrchestrationError (
780+ 'not_found' ,
781+ `${ name } could not find this document: it was deleted, moved, or is not a readable page. Search again or read a different result.`
782+ )
783+ if ( error . httpStatus !== undefined && error . httpStatus >= 500 )
784+ return new LiveReadError (
785+ `${ name } is temporarily unavailable (${ error . httpStatus } ). Try again shortly.` ,
786+ true
787+ )
788+ return new LiveReadError ( error . message , false )
789+ }
790+ if ( deadline ?. aborted || ( error instanceof McpError && error . code === ErrorCode . RequestTimeout ) )
791+ return new LiveReadError (
792+ `${ name } took too long to return this document. Try again, or read a different result.` ,
793+ true
794+ )
795+ return error
796+ }
797+
745798export const readLiveDocument = defineAuthorizedKnowledgeUseCase ( {
746799 operation : knowledgeOperations . readDocument ,
747800 resolveContext : ( { input } : { input : LiveReadInput } ) => resolveKnowledgeOwnerContext ( input ) ,
@@ -761,52 +814,59 @@ export const readLiveDocument = defineAuthorizedKnowledgeUseCase({
761814 ( input . filters ?. documentIds && ! input . filters . documentIds . includes ( input . documentId ) )
762815 )
763816 throw new OrchestrationError ( 'not_found' , 'Document is outside the selected search filters' )
764- const [ resolved , policies ] = await Promise . all ( [
765- resolveLiveAccount ( input , userId , reference . account ) ,
766- loadLiveSearchPolicies ( input ) ,
767- ] )
768- if ( resolved . account . provider !== reference . provider )
769- throw new OrchestrationError ( 'not_found' , 'Document account changed' )
770- const signal = input . signal
771- ? AbortSignal . any ( [ input . signal , AbortSignal . timeout ( 15_000 ) ] )
772- : AbortSignal . timeout ( 15_000 )
773- const pool = createPinnedConnectionPool ( )
817+ let resolved : ResolvedLiveAccount
774818 let document : NativeDocument
775- let session : LiveAccountSession | undefined
819+ let deadline : AbortSignal | undefined
776820 try {
777- session = await openLiveAccountSession ( {
778- owner : input ,
779- userId,
780- resolved,
781- policies,
782- signal,
783- pool,
784- } )
785- if ( ! ( await session . verify ( reference ) ) )
786- throw new OrchestrationError (
787- 'not_found' ,
788- 'Document is outside your organization’s search scope'
789- )
790- const currentSession = session
791- document = await measureSearchStage ( 'live.read' , ( ) =>
792- currentSession . read ( reference , input . filters )
793- )
794- /** Readers degrade section failures to warnings, so the signal decides cancellation. */
795- signal . throwIfAborted ( )
796- const current = await session . verifyCurrent ( document )
797- /** A verifier may report a check cut short by cancellation as a normal result. */
798- signal . throwIfAborted ( )
799- if ( ! current )
800- throw new OrchestrationError (
801- 'not_found' ,
802- 'Document is outside your organization’s search scope'
803- )
804- } finally {
821+ const [ account , policies ] = await Promise . all ( [
822+ resolveLiveAccount ( input , userId , reference . account ) ,
823+ loadLiveSearchPolicies ( input ) ,
824+ ] )
825+ resolved = account
826+ if ( resolved . account . provider !== reference . provider )
827+ throw new OrchestrationError ( 'not_found' , 'Document account changed' )
828+ deadline = AbortSignal . timeout ( READ_DEADLINE_MS )
829+ const signal = input . signal ? AbortSignal . any ( [ input . signal , deadline ] ) : deadline
830+ const pool = createPinnedConnectionPool ( )
831+ let session : LiveAccountSession | undefined
805832 try {
806- await session ?. close ( )
833+ session = await openLiveAccountSession ( {
834+ owner : input ,
835+ userId,
836+ resolved,
837+ policies,
838+ signal,
839+ pool,
840+ } )
841+ if ( ! ( await session . verify ( reference ) ) )
842+ throw new OrchestrationError (
843+ 'not_found' ,
844+ 'Document is outside your organization’s search scope'
845+ )
846+ const currentSession = session
847+ document = await measureSearchStage ( 'live.read' , ( ) =>
848+ currentSession . read ( reference , input . filters )
849+ )
850+ /** Readers degrade section failures to warnings, so the signal decides cancellation. */
851+ signal . throwIfAborted ( )
852+ const current = await session . verifyCurrent ( document )
853+ /** A verifier may report a check cut short by cancellation as a normal result. */
854+ signal . throwIfAborted ( )
855+ if ( ! current )
856+ throw new OrchestrationError (
857+ 'not_found' ,
858+ 'Document is outside your organization’s search scope'
859+ )
807860 } finally {
808- pool . destroy ( )
861+ try {
862+ await session ?. close ( )
863+ } finally {
864+ pool . destroy ( )
865+ }
809866 }
867+ } catch ( error ) {
868+ if ( input . signal ?. aborted ) throw error
869+ throw liveReadFailure ( error , reference . provider , deadline )
810870 }
811871 if ( ! matchesLiveFilters ( document , input . documentId , reference . provider , input . filters ) )
812872 throw new OrchestrationError ( 'not_found' , 'Document is outside the selected search filters' )
0 commit comments