Luau Execution API (#18)
All checks were successful
continuous-integration/drone/push Build is passing

Tested to some extent

Reviewed-on: #18
Co-authored-by: Rhys Lloyd <krakow20@gmail.com>
Co-committed-by: Rhys Lloyd <krakow20@gmail.com>
This commit was merged in pull request #18.
This commit is contained in:
2025-08-09 03:26:03 +00:00
committed by Quaternions
parent 8a400faae2
commit dd4344f514
2 changed files with 197 additions and 2 deletions

View File

@@ -1,3 +1,4 @@
use crate::body::{Binary,ContentType,Json};
use crate::util::{serialize_u64,deserialize_u64,response_ok}; use crate::util::{serialize_u64,deserialize_u64,response_ok};
use crate::types::{ResponseError,MaybeGzippedBytes}; use crate::types::{ResponseError,MaybeGzippedBytes};
@@ -326,6 +327,102 @@ impl RobloxOperation{
} }
} }
#[derive(Debug)]
pub enum LuauSessionError{
Get(GetError),
Unspecified,
NotDone,
NoOutput,
NoError,
}
impl std::fmt::Display for LuauSessionError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for LuauSessionError{}
#[derive(Debug,serde::Serialize)]
#[expect(nonstandard_style)]
pub struct LuauSessionCreate<'a>{
pub script:&'a str,
#[serde(skip_serializing_if="Option::is_none")]
pub user:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub timeout:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub binaryInput:Option<&'a str>,
#[serde(skip_serializing_if="Option::is_none")]
pub enableBinaryOutput:Option<bool>,
#[serde(skip_serializing_if="Option::is_none")]
pub binaryOutputUri:Option<&'a str>,
}
#[derive(Debug,serde::Deserialize)]
#[expect(nonstandard_style)]
pub enum LuauSessionState{
STATE_UNSPECIFIED,
PROCESSING,
COMPLETE,
FAILED,
}
#[derive(Debug,serde::Deserialize)]
pub struct LuauError{
pub code:String,
pub message:String,
}
#[derive(Debug,serde::Deserialize)]
pub struct LuauResults{
pub results:Vec<serde_json::Value>,
}
#[derive(Debug,serde::Deserialize)]
#[expect(nonstandard_style)]
pub struct LuauSessionResponse{
path:String,
#[serde(deserialize_with="deserialize_u64")]
pub user:u64,
pub state:LuauSessionState,
pub script:String,
pub error:Option<LuauError>,
pub output:Option<LuauResults>,
pub binaryInput:String,
pub enableBinaryOutput:bool,
pub binaryOutputUri:String,
}
impl LuauSessionResponse{
pub fn path(&self)->&str{
&self.path
}
pub async fn try_get_result(&self,context:&Context)->Result<Result<LuauResults,LuauError>,LuauSessionError>{
let response=context.get_luau_session(self).await.map_err(LuauSessionError::Get)?;
match response.state{
LuauSessionState::STATE_UNSPECIFIED=>Err(LuauSessionError::Unspecified),
LuauSessionState::PROCESSING=>Err(LuauSessionError::NotDone),
LuauSessionState::COMPLETE=>Ok(Ok(response.output.ok_or(LuauSessionError::NoOutput)?)),
LuauSessionState::FAILED=>Ok(Err(response.error.ok_or(LuauSessionError::NoError)?)),
}
}
}
pub trait AsSessionPath{
fn into_session_path(&self)->impl AsRef<str>;
}
impl AsSessionPath for LuauSessionResponse{
fn into_session_path(&self)->impl AsRef<str>{
&self.path
}
}
pub struct LuauSessionRequest{
pub universe_id:u64,
pub place_id:u64,
pub version_id:u64,
}
impl AsSessionPath for LuauSessionRequest{
fn into_session_path(&self)->impl AsRef<str>{
let universe_id=self.universe_id;
let place_id=self.place_id;
let version_id=self.version_id;
format!("universes/{universe_id}/places/{place_id}/versions/{version_id}/luau-execution-session-tasks")
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ApiKey(String); pub struct ApiKey(String);
impl ApiKey{ impl ApiKey{
@@ -355,9 +452,10 @@ impl Context{
.header("x-api-key",self.api_key.as_str()) .header("x-api-key",self.api_key.as_str())
.send().await .send().await
} }
async fn post(&self,url:url::Url,body:impl Into<reqwest::Body>+Clone)->Result<reqwest::Response,reqwest::Error>{ async fn post(&self,url:url::Url,body:impl ContentType)->Result<reqwest::Response,reqwest::Error>{
self.client.post(url) self.client.post(url)
.header("x-api-key",self.api_key.as_str()) .header("x-api-key",self.api_key.as_str())
.header("Content-Type",body.content_type())
.body(body) .body(body)
.send().await .send().await
} }
@@ -423,6 +521,26 @@ impl Context{
).await.map_err(GetError::Response)? ).await.map_err(GetError::Response)?
.json::<RobloxOperation>().await.map_err(GetError::Reqwest) .json::<RobloxOperation>().await.map_err(GetError::Reqwest)
} }
pub async fn create_luau_session(&self,config:&impl AsSessionPath,session:LuauSessionCreate<'_>)->Result<LuauSessionResponse,CreateError>{
let raw_url=format!("https://apis.roblox.com/cloud/v2/{}",config.into_session_path().as_ref());
let url=reqwest::Url::parse(raw_url.as_str()).map_err(CreateError::Parse)?;
let body=serde_json::to_string(&session).map_err(CreateError::Serialize)?;
response_ok(
self.post(url,Json(body)).await.map_err(CreateError::Reqwest)?
).await.map_err(CreateError::Response)?
.json::<LuauSessionResponse>().await.map_err(CreateError::Reqwest)
}
pub async fn get_luau_session(&self,config:&impl AsSessionPath)->Result<LuauSessionResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/cloud/v2/{}",config.into_session_path().as_ref());
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
response_ok(
self.get(url).await.map_err(GetError::Reqwest)?
).await.map_err(GetError::Response)?
.json::<LuauSessionResponse>().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_info(&self,config:GetAssetLatestRequest)->Result<AssetResponse,GetError>{ pub async fn get_asset_info(&self,config:GetAssetLatestRequest)->Result<AssetResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.asset_id); let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?; let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::Parse)?;
@@ -503,7 +621,7 @@ impl Context{
} }
response_ok( response_ok(
self.post(url,body).await.map_err(UpdateError::Reqwest)? self.post(url,Binary(body)).await.map_err(UpdateError::Reqwest)?
).await.map_err(UpdateError::Response)? ).await.map_err(UpdateError::Response)?
.json::<UpdatePlaceResponse>().await.map_err(UpdateError::Reqwest) .json::<UpdatePlaceResponse>().await.map_err(UpdateError::Reqwest)
} }

View File

@@ -42,6 +42,7 @@ enum Commands{
Decompile(DecompileSubcommand), Decompile(DecompileSubcommand),
DecompileHistoryIntoGit(DecompileHistoryIntoGitSubcommand), DecompileHistoryIntoGit(DecompileHistoryIntoGitSubcommand),
DownloadAndDecompileHistoryIntoGit(DownloadAndDecompileHistoryIntoGitSubcommand), DownloadAndDecompileHistoryIntoGit(DownloadAndDecompileHistoryIntoGitSubcommand),
RunLuau(RunLuauSubcommand),
} }
/// Download a range of assets from the asset version history. Download summary is saved to `output_folder/versions.json`, and can be optionally used to download only new versions the next time. /// Download a range of assets from the asset version history. Download summary is saved to `output_folder/versions.json`, and can be optionally used to download only new versions the next time.
@@ -427,6 +428,26 @@ struct DownloadAndDecompileHistoryIntoGitSubcommand{
#[arg(long)] #[arg(long)]
write_scripts:Option<bool>, write_scripts:Option<bool>,
} }
/// Run a Luau script.
#[derive(Args)]
struct RunLuauSubcommand{
#[arg(long,group="api_key",required=true)]
api_key_literal:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_envvar:Option<String>,
#[arg(long,group="api_key",required=true)]
api_key_file:Option<PathBuf>,
#[arg(long,group="script",required=true)]
script_literal:Option<String>,
#[arg(long,group="script",required=true)]
script_file:Option<PathBuf>,
#[arg(long)]
universe_id:u64,
#[arg(long)]
place_id:u64,
#[arg(long)]
version_id:u64,
}
#[derive(Clone,Copy,Debug,clap::ValueEnum)] #[derive(Clone,Copy,Debug,clap::ValueEnum)]
enum Style{ enum Style{
@@ -738,6 +759,22 @@ async fn main()->AResult<()>{
write_models:subcommand.write_models.unwrap_or(false), write_models:subcommand.write_models.unwrap_or(false),
write_scripts:subcommand.write_scripts.unwrap_or(true), write_scripts:subcommand.write_scripts.unwrap_or(true),
}).await, }).await,
Commands::RunLuau(subcommand)=>run_luau(RunLuauConfig{
api_key:api_key_from_args(
subcommand.api_key_literal,
subcommand.api_key_envvar,
subcommand.api_key_file,
).await?,
script:match subcommand.script_literal{
Some(script)=>script,
None=>std::fs::read_to_string(subcommand.script_file.unwrap())?,
},
request:rbx_asset::cloud::LuauSessionRequest{
place_id:subcommand.place_id,
universe_id:subcommand.universe_id,
version_id:subcommand.version_id,
},
}).await,
} }
} }
@@ -1744,3 +1781,43 @@ async fn compile_upload_place(config:CompileUploadPlaceConfig)->AResult<()>{
println!("UploadResponse={:?}",resp); println!("UploadResponse={:?}",resp);
Ok(()) Ok(())
} }
async fn get_luau_result_exp_backoff(
context:&CloudContext,
luau_session:&rbx_asset::cloud::LuauSessionResponse
)->Result<Result<rbx_asset::cloud::LuauResults,rbx_asset::cloud::LuauError>,rbx_asset::cloud::LuauSessionError>{
const BACKOFF_MUL:f32=1.395_612_5;//exp(1/3)
let mut backoff=1000f32;
loop{
match luau_session.try_get_result(context).await{
//try again when the operation is not done
Err(rbx_asset::cloud::LuauSessionError::NotDone)=>(),
//return all other results
other_result=>return other_result,
}
println!("Operation not complete; waiting {:.0}ms...",backoff);
tokio::time::sleep(std::time::Duration::from_millis(backoff as u64)).await;
backoff*=BACKOFF_MUL;
}
}
struct RunLuauConfig{
api_key:ApiKey,
script:String,
request:rbx_asset::cloud::LuauSessionRequest,
}
async fn run_luau(config:RunLuauConfig)->AResult<()>{
let context=CloudContext::new(config.api_key);
let session=rbx_asset::cloud::LuauSessionCreate{
script:&config.script,
user:None,
timeout:None,
binaryInput:None,
enableBinaryOutput:None,
binaryOutputUri:None,
};
let response=context.create_luau_session(&config.request,session).await?;
dbg!(&response);
let result=get_luau_result_exp_backoff(&context,&response).await?;
dbg!(&result);
Ok(())
}