import every from 'lodash/every' ; import { combineEpics , ofType } from 'redux-observable' ; import { of , interval , concat , EMPTY } from 'rxjs' ; import { filter , map , mapTo , switchMap , mergeMap , takeUntil , withLatestFrom , } from 'rxjs/operators' ; import { getAllRobots , getRobotApiVersion } from '../discovery' ; import { startDiscovery , finishDiscovery , removeRobot , } from '../discovery/actions' ; import { GET , POST , fetchRobotApi } from '../robot-api' ; import { RESTART_PATH , RESTART_STATUS_CHANGED , RESTART_SUCCEEDED_STATUS , RESTART_TIMED_OUT_STATUS , restartRobotSuccess , } from '../robot-admin' ; import { actions as robotActions } from '../robot' ; import { getBuildrootTargetVersion , getBuildrootSession , getBuildrootRobotName , getBuildrootRobot , } from './selectors' ; import { startBuildrootUpdate , startBuildrootPremigration , readUserBuildrootFile , createSession , createSessionSuccess , buildrootStatus , uploadBuildrootFile , setBuildrootSessionStep , unexpectedBuildrootError , } from './actions' ; import { PREMIGRATION_RESTART , GET_TOKEN , PROCESS_FILE , COMMIT_UPDATE , RESTART , FINISHED , AWAITING_FILE , DONE , READY_FOR_RESTART , BR_START_UPDATE , BR_USER_FILE_INFO , BR_CREATE_SESSION , BR_CREATE_SESSION_SUCCESS , } from './constants' ; import type { Observable } from 'rxjs' ; import type { State , Action , Epic } from '../types' ; import type { ViewableRobot } from '../discovery/types' ; import type { RobotApiResponse } from '../robot-api/types' ; import type { RestartStatusChangedAction } from '../robot-admin/types' ; import type { BuildrootAction , StartBuildrootUpdateAction , CreateSessionAction , CreateSessionSuccessAction , BuildrootUpdateSession , BuildrootStatusAction , } from './types' ; export const POLL_INTERVAL_MS = 2000 ; export const REDISCOVERY_TIME_MS = 1200000 ; const UNABLE_TO_FIND_ROBOT_WITH_NAME = 'Unable to find online robot with name' ; const ROBOT_HAS_BAD_CAPABILITIES = 'Robot has incorrect capabilities shape' ; const UNABLE_TO_START_UPDATE_SESSION = 'Unable to start update session' ; const UNABLE_TO_CANCEL_UPDATE_SESSION = 'Unable to cancel in-progress update session' ; const UNABLE_TO_COMMIT_UPDATE = 'Unable to commit update' ; const UNABLE_TO_RESTART_ROBOT = 'Unable to restart robot' ; const ROBOT_RECONNECTED_WITH_VERSION = 'Robot reconnected with version' ; const ROBOT_DID_NOT_RECONNECT = 'Robot did not successfully reconnect' ; const BUT_WE_EXPECTED = 'but we expected' ; const UNKNOWN = 'unknown' ; const CHECK_TO_VERIFY_UPDATE = "Check your robot's settings page to verify whether or not the update was successful." ; export const startUpdateEpic = ( action$ , state$ ) => action$ . pipe ( ofType < Action , StartBuildrootUpdateAction > ( BR_START_UPDATE ) , withLatestFrom ( state$ ) , map < [ StartBuildrootUpdateAction , State ] , any > ( ( [ action , state ] ) => { const { robotName , systemFile } = action . payload ; const host = getBuildrootRobot ( state ) ; const serverHealth = host ?. serverHealth || null ; if ( host === null || serverHealth === null ) { return unexpectedBuildrootError ( `${ UNABLE_TO_FIND_ROBOT_WITH_NAME } ${ robotName }` ) ; } const capabilities = serverHealth . capabilities || null ; if ( systemFile !== null ) { return readUserBuildrootFile ( systemFile ) ; } if ( capabilities === null ) { return startBuildrootPremigration ( host ) ; } const sessionPath = capabilities . buildrootUpdate || capabilities . buildrootMigration ; if ( sessionPath == null ) { return unexpectedBuildrootError ( `${ ROBOT_HAS_BAD_CAPABILITIES }: ${ JSON . stringify ( capabilities ) }` ) ; } return createSession ( host , sessionPath ) ; } ) ) ; export const retryAfterPremigrationEpic = ( _ , state$ ) => { return state$ . pipe ( switchMap ( state => { const session = getBuildrootSession ( state ) ; const robot = getBuildrootRobot ( state ) ; return robot !== null && session ?. step === PREMIGRATION_RESTART && robot . serverHealth ?. capabilities != null ? of ( startBuildrootUpdate ( robot . name ) ) : EMPTY ; } ) ) ; } ; export const retryAfterUserFileInfoEpic = ( action$ , state$ ) => { return action$ . pipe ( ofType ( BR_USER_FILE_INFO ) , withLatestFrom ( state$ , ( _ , state ) => getBuildrootRobotName ( state ) ) , filter ( ( robotName ) : robotName is string => robotName !== null ) , map < string , any > ( robotName => startBuildrootUpdate ( robotName ) ) ) ; } ; export const createSessionEpic = action$ => { return action$ . pipe ( ofType ( BR_CREATE_SESSION ) , switchMap < CreateSessionAction , ReturnType < typeof fetchRobotApi > > ( createAction => { const { host , sessionPath } = createAction . payload ; return fetchRobotApi ( host , { method : POST , path : sessionPath } ) ; } ) , switchMap ( resp => { const { host , path , ok , status } = resp ; const pathPrefix = path . replace ( '/begin' , '' ) ; if ( ok ) { return of ( createSessionSuccess ( host , resp . body . token , pathPrefix ) ) ; } if ( ! ok && status === 409 ) { return fetchRobotApi ( host , { method : POST , path : `${ pathPrefix }/cancel` , } ) . pipe ( map ( cancelResp => { return cancelResp . ok ? createSession ( host , path ) : unexpectedBuildrootError ( UNABLE_TO_CANCEL_UPDATE_SESSION ) ; } ) ) ; } return of ( unexpectedBuildrootError ( UNABLE_TO_START_UPDATE_SESSION ) ) ; } ) ) ; } ; export const statusPollEpic = ( action$ , state$ ) => { return action$ . pipe ( ofType ( BR_CREATE_SESSION_SUCCESS ) , mergeMap < CreateSessionSuccessAction , Observable < BuildrootStatusAction > > ( action => { const { host , token , pathPrefix } = action . payload ; const request = { method : GET , path : `${ pathPrefix }/${ token }/status` } ; return interval ( POLL_INTERVAL_MS ) . pipe ( takeUntil ( state$ . pipe ( filter ( state => { const session = getBuildrootSession ( state ) ; return ( session ?. stage === READY_FOR_RESTART || session ?. error === true || session === null ) ; } ) ) ) , switchMap ( ( ) => fetchRobotApi ( host , request ) ) , filter ( resp => resp . ok ) , map < RobotApiResponse , BuildrootStatusAction > ( successResp => buildrootStatus ( successResp . body . stage , successResp . body . message , successResp . body . progress != null ? Math . round ( successResp . body . progress * 100 ) : null ) ) ) ; } ) ) ; } ; const passActiveSession = ( props ) => ( state ) : boolean => { const robot = getBuildrootRobot ( state ) ; const session = getBuildrootSession ( state ) ; return ( robot !== null && ! session ?. error && typeof session ?. pathPrefix === 'string' && typeof session ?. token === 'string' && every ( props , ( value , key ) => session ?. [ key as keyof BuildrootUpdateSession ] === value ) ) ; } ; export const uploadFileEpic = ( _ , state$ ) => { return state$ . pipe ( filter ( passActiveSession ( { stage : AWAITING_FILE , step : GET_TOKEN } ) ) , map < State , ReturnType < typeof uploadBuildrootFile > > ( stateWithSession => { const host = getBuildrootRobot ( stateWithSession ) as any ; const session = getBuildrootSession ( stateWithSession ) ; const pathPrefix = session ?. pathPrefix as any ; const token = session ?. token as any ; const systemFile = session ?. userFileInfo ?. systemFile || null ; return uploadBuildrootFile ( host , `${ pathPrefix }/${ token }/file` , systemFile ) ; } ) ) ; } ; export const commitUpdateEpic = ( _ , state$ ) => { return state$ . pipe ( filter ( passActiveSession ( { stage : DONE , step : PROCESS_FILE } ) ) , switchMap < State , Observable < BuildrootAction > > ( stateWithSession => { const host = getBuildrootRobot ( stateWithSession ) as any ; const session = getBuildrootSession ( stateWithSession ) ; const pathPrefix = session ?. pathPrefix as any ; const token = session ?. token as any ; const path = `${ pathPrefix }/${ token }/commit` ; const request$ = fetchRobotApi ( host , { method : POST , path } ) . pipe ( filter ( resp => ! resp . ok ) , map ( resp => { return unexpectedBuildrootError ( `${ UNABLE_TO_COMMIT_UPDATE }: ${ resp . body . message }` ) ; } ) ) ; return concat ( of ( setBuildrootSessionStep ( COMMIT_UPDATE ) ) , request$ ) ; } ) ) ; } ; export const restartAfterCommitEpic = ( _ , state$ ) => { return state$ . pipe ( filter ( passActiveSession ( { stage : READY_FOR_RESTART , step : COMMIT_UPDATE } ) ) , switchMap < State , Observable < any > > ( stateWithSession => { const host = getBuildrootRobot ( stateWithSession ) as any ; const path = host . serverHealth ?. capabilities ?. restart || RESTART_PATH ; const request$ = fetchRobotApi ( host , { method : POST , path } ) . pipe ( switchMap ( resp => { return resp . ok ? of ( startDiscovery ( REDISCOVERY_TIME_MS ) , restartRobotSuccess ( host . name , { } ) ) : of ( unexpectedBuildrootError ( `${ UNABLE_TO_RESTART_ROBOT }: ${ resp . body . message }` ) ) ; } ) ) ; return concat ( of ( setBuildrootSessionStep ( RESTART ) ) , request$ ) ; } ) ) ; } ; export const finishAfterRestartEpic = ( action$ , state$ ) => { return action$ . pipe ( ofType ( RESTART_STATUS_CHANGED ) , withLatestFrom < RestartStatusChangedAction , [ RestartStatusChangedAction , State ] > ( state$ ) , filter ( ( [ action , state ] ) => { const session = getBuildrootSession ( state ) ; const robot = getBuildrootRobot ( state ) ; const restartDone = action . payload . restartStatus === RESTART_SUCCEEDED_STATUS || action . payload . restartStatus === RESTART_TIMED_OUT_STATUS ; return ( restartDone && robot ?. name === action . payload . robotName && ! session ?. error && session ?. step === RESTART ) ; } ) , switchMap ( ( [ action , stateWithRobot ] ) => { const targetVersion = getBuildrootTargetVersion ( stateWithRobot ) ; const robot = getBuildrootRobot ( stateWithRobot ) as any ; const robotVersion = getRobotApiVersion ( robot ) ; const timedOut = action . payload . restartStatus === RESTART_TIMED_OUT_STATUS ; const actual = robotVersion ?? UNKNOWN ; const expected = targetVersion ?? UNKNOWN ; let finishAction ; if ( targetVersion != null && robotVersion != null && robotVersion === targetVersion ) { finishAction = setBuildrootSessionStep ( FINISHED ) ; } else if ( timedOut ) { finishAction = unexpectedBuildrootError ( `${ ROBOT_DID_NOT_RECONNECT }. ${ CHECK_TO_VERIFY_UPDATE }.` ) ; } else { finishAction = unexpectedBuildrootError ( `${ ROBOT_RECONNECTED_WITH_VERSION } ${ actual }, ${ BUT_WE_EXPECTED } ${ expected }. ${ CHECK_TO_VERIFY_UPDATE }.` ) ; } return of ( finishAction , finishDiscovery ( ) ) ; } ) ) ; } ; export const removeMigratedRobotsEpic = ( _ , state$ ) => { return state$ . pipe ( filter ( state => { const robotName = getBuildrootRobotName ( state ) ; const robot = getBuildrootRobot ( state ) ; const allRobots = getAllRobots ( state ) ; return ( robot !== null && robotName !== null && robot . name !== robotName && allRobots . some ( r => r . name === robotName ) ) ; } ) , map < State , ReturnType < typeof removeRobot > > ( stateWithRobotName => { const robotName = getBuildrootRobotName ( stateWithRobotName ) as any ; return removeRobot ( robotName ) ; } ) ) ; } ; export const disconnectRpcOnStartEpic = action$ => { return action$ . pipe ( ofType ( BR_START_UPDATE ) , mapTo ( robotActions . disconnect ( ) ) ) ; } ; export const buildrootEpic = combineEpics < Epic > ( startUpdateEpic , retryAfterPremigrationEpic , retryAfterUserFileInfoEpic , createSessionEpic , statusPollEpic , uploadFileEpic , commitUpdateEpic , restartAfterCommitEpic , finishAfterRestartEpic , removeMigratedRobotsEpic , disconnectRpcOnStartEpic ) ;