@@ -31,7 +31,9 @@ const parseHealthRevision = (text: string): string | null =>
3131 }
3232 } )
3333
34- const probeHealth = ( apiBaseUrl : string ) : Effect . Effect < HealthProbeResult , ControllerBootstrapError > =>
34+ const probeHealth = (
35+ apiBaseUrl : string
36+ ) : Effect . Effect < HealthProbeResult , ControllerBootstrapError , HttpClient . HttpClient > =>
3537 Effect . gen ( function * ( _ ) {
3638 const client = yield * _ ( HttpClient . HttpClient )
3739 const response = yield * _ ( client . get ( `${ apiBaseUrl } /health` , { headers : { accept : "application/json" } } ) )
@@ -52,7 +54,6 @@ const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, Contr
5254 )
5355 )
5456 } ) . pipe (
55- Effect . provide ( FetchHttpClient . layer ) ,
5657 Effect . mapError ( ( error ) : ControllerBootstrapError =>
5758 error . _tag === "ControllerBootstrapError"
5859 ? error
@@ -64,45 +65,98 @@ const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, Contr
6465 )
6566
6667const findReachableHealthProbe = (
67- candidateUrls : ReadonlyArray < string >
68- ) : Effect . Effect < HealthProbeResult , ControllerBootstrapError > =>
68+ candidateUrls : ReadonlyArray < string > ,
69+ expectedRevision ?: string
70+ ) : Effect . Effect < HealthProbeResult , ControllerBootstrapError , HttpClient . HttpClient > =>
6971 Effect . gen ( function * ( _ ) {
7072 if ( candidateUrls . length === 0 ) {
7173 return yield * _ (
7274 Effect . fail ( controllerBootstrapError ( "No docker-git controller endpoint candidates were generated." ) )
7375 )
7476 }
7577
78+ const mismatches : Array < string > = [ ]
7679 for ( const candidateUrl of candidateUrls ) {
7780 const healthy = yield * _ ( probeHealth ( candidateUrl ) . pipe ( Effect . either ) )
78- if ( Either . isRight ( healthy ) ) {
81+ if ( Either . isLeft ( healthy ) ) {
82+ continue
83+ }
84+ if ( matchesExpectedRevision ( healthy . right , expectedRevision ) ) {
7985 return healthy . right
8086 }
87+ mismatches . push ( describeRevisionMismatch ( healthy . right ) )
8188 }
8289
83- return yield * _ ( Effect . fail ( controllerBootstrapError ( "No docker-git controller endpoint responded to /health." ) ) )
90+ return yield * _ ( Effect . fail ( noMatchingHealthProbeError ( expectedRevision , mismatches ) ) )
8491 } )
8592
8693const findReachableHealthProbeOrNull = (
87- candidateUrls : ReadonlyArray < string >
88- ) : Effect . Effect < HealthProbeResult | null > =>
89- findReachableHealthProbe ( candidateUrls ) . pipe (
94+ candidateUrls : ReadonlyArray < string > ,
95+ expectedRevision ?: string
96+ ) : Effect . Effect < HealthProbeResult | null , never , HttpClient . HttpClient > =>
97+ findReachableHealthProbe ( candidateUrls , expectedRevision ) . pipe (
9098 Effect . match ( {
9199 onFailure : ( ) => null ,
92100 onSuccess : ( probe ) => probe
93101 } )
94102 )
95103
104+ const matchesExpectedRevision = (
105+ probe : HealthProbeResult ,
106+ expectedRevision : string | undefined
107+ ) : boolean => expectedRevision === undefined || probe . revision === expectedRevision
108+
109+ const describeRevisionMismatch = ( probe : HealthProbeResult ) : string =>
110+ `${ probe . apiBaseUrl } revision ${ probe . revision ?? "unknown" } `
111+
112+ const noMatchingHealthProbeError = (
113+ expectedRevision : string | undefined ,
114+ mismatches : ReadonlyArray < string >
115+ ) : ControllerBootstrapError =>
116+ expectedRevision !== undefined && mismatches . length > 0
117+ ? controllerBootstrapError (
118+ `No docker-git controller endpoint with revision ${ expectedRevision } responded. ` +
119+ `Reachable mismatched controllers: ${ mismatches . join ( ", " ) } .`
120+ )
121+ : controllerBootstrapError ( "No docker-git controller endpoint responded to /health." )
122+
123+ export const findReachableApiBaseUrlWithHttpClient = (
124+ candidateUrls : ReadonlyArray < string > ,
125+ expectedRevision ?: string
126+ ) : Effect . Effect < string , ControllerBootstrapError , HttpClient . HttpClient > =>
127+ findReachableHealthProbe ( candidateUrls , expectedRevision ) . pipe ( Effect . map ( ( { apiBaseUrl } ) => apiBaseUrl ) )
128+
96129export const findReachableApiBaseUrl = (
97- candidateUrls : ReadonlyArray < string >
130+ candidateUrls : ReadonlyArray < string > ,
131+ expectedRevision ?: string
98132) : Effect . Effect < string , ControllerBootstrapError > =>
99- findReachableHealthProbe ( candidateUrls ) . pipe ( Effect . map ( ( { apiBaseUrl } ) => apiBaseUrl ) )
133+ findReachableApiBaseUrlWithHttpClient ( candidateUrls , expectedRevision ) . pipe ( Effect . provide ( FetchHttpClient . layer ) )
100134
101- export const findReachableDirectHealthProbe = ( options : {
135+ // CHANGE: select only controller endpoints that prove the expected source revision.
136+ // WHY: containerized hosts can see stale controllers through host.docker.internal before the current local controller is reachable.
137+ // QUOTE(ТЗ): "проверь сам что Open Browser кнопка работает"
138+ // REF: user-message-2026-05-29-open-browser-e2e
139+ // SOURCE: n/a
140+ // FORMAT THEOREM: selected(endpoint) -> health(endpoint).revision = expectedRevision
141+ // PURITY: SHELL
142+ // EFFECT: FetchHttpClient health probes.
143+ // INVARIANT: mismatched reachable controllers are rejected rather than reused.
144+ // COMPLEXITY: O(n) health probes where n = |candidateUrls|.
145+ export const findReachableApiBaseUrlMatchingRevision = (
146+ candidateUrls : ReadonlyArray < string > ,
147+ expectedRevision : string
148+ ) : Effect . Effect < string , ControllerBootstrapError > => findReachableApiBaseUrl ( candidateUrls , expectedRevision )
149+
150+ type DirectHealthProbeOptions = {
102151 readonly explicitApiBaseUrl : string | undefined
103152 readonly defaultLocalApiBaseUrl : string | undefined
104153 readonly cachedApiBaseUrl : string | undefined
105- } ) : Effect . Effect < HealthProbeResult | null > =>
154+ readonly expectedRevision ?: string | undefined
155+ }
156+
157+ export const findReachableDirectHealthProbeWithHttpClient = (
158+ options : DirectHealthProbeOptions
159+ ) : Effect . Effect < HealthProbeResult | null , never , HttpClient . HttpClient > =>
106160 findReachableHealthProbeOrNull (
107161 buildApiBaseUrlCandidates ( {
108162 explicitApiBaseUrl : options . explicitApiBaseUrl ,
@@ -112,5 +166,11 @@ export const findReachableDirectHealthProbe = (options: {
112166 currentContainerNetworks : { } ,
113167 controllerNetworks : { } ,
114168 port : resolveApiPort ( )
115- } )
169+ } ) ,
170+ options . expectedRevision
116171 )
172+
173+ export const findReachableDirectHealthProbe = (
174+ options : DirectHealthProbeOptions
175+ ) : Effect . Effect < HealthProbeResult | null > =>
176+ findReachableDirectHealthProbeWithHttpClient ( options ) . pipe ( Effect . provide ( FetchHttpClient . layer ) )
0 commit comments