mirror of
https://github.com/openai/codex.git
synced 2026-05-01 03:42:05 +03:00
feat: external artifacts builder (#13485)
This PR reverts the built-in artifact render while a decision is being reached. No impact expected on any features
This commit is contained in:
446
codex-rs/artifacts/src/client.rs
Normal file
446
codex-rs/artifacts/src/client.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
use crate::ArtifactRuntimeError;
|
||||
use crate::ArtifactRuntimeManager;
|
||||
use crate::InstalledArtifactRuntime;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactsClient {
|
||||
runtime_source: RuntimeSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum RuntimeSource {
|
||||
Managed(ArtifactRuntimeManager),
|
||||
Installed(InstalledArtifactRuntime),
|
||||
}
|
||||
|
||||
impl ArtifactsClient {
|
||||
pub fn from_runtime_manager(runtime_manager: ArtifactRuntimeManager) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Managed(runtime_manager),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_installed_runtime(runtime: InstalledArtifactRuntime) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Installed(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_build(
|
||||
&self,
|
||||
request: ArtifactBuildRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let staging_dir = TempDir::new().map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to create build staging directory".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let script_path = staging_dir.path().join("artifact-build.mjs");
|
||||
let wrapped_script = build_wrapped_script(&request.source);
|
||||
fs::write(&script_path, wrapped_script)
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: format!("failed to write {}", script_path.display()),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
command
|
||||
.arg(&script_path)
|
||||
.current_dir(&request.cwd)
|
||||
.env("CODEX_ARTIFACT_BUILD_ENTRYPOINT", runtime.build_js_path())
|
||||
.env(
|
||||
"CODEX_ARTIFACT_RENDER_ENTRYPOINT",
|
||||
runtime.render_cli_path(),
|
||||
)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
run_command(
|
||||
command,
|
||||
request.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn execute_render(
|
||||
&self,
|
||||
request: ArtifactRenderCommandRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
command
|
||||
.arg(runtime.render_cli_path())
|
||||
.args(request.target.to_args())
|
||||
.current_dir(&request.cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
run_command(
|
||||
command,
|
||||
request.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_runtime(&self) -> Result<InstalledArtifactRuntime, ArtifactsError> {
|
||||
match &self.runtime_source {
|
||||
RuntimeSource::Installed(runtime) => Ok(runtime.clone()),
|
||||
RuntimeSource::Managed(manager) => manager.ensure_installed().await.map_err(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArtifactBuildRequest {
|
||||
pub source: String,
|
||||
pub cwd: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRenderCommandRequest {
|
||||
pub cwd: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub target: ArtifactRenderTarget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArtifactRenderTarget {
|
||||
Presentation(PresentationRenderTarget),
|
||||
Spreadsheet(SpreadsheetRenderTarget),
|
||||
}
|
||||
|
||||
impl ArtifactRenderTarget {
|
||||
pub fn to_args(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Presentation(target) => {
|
||||
vec![
|
||||
"pptx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
target.input_path.display().to_string(),
|
||||
"--slide".to_string(),
|
||||
target.slide_number.to_string(),
|
||||
"--out".to_string(),
|
||||
target.output_path.display().to_string(),
|
||||
]
|
||||
}
|
||||
Self::Spreadsheet(target) => {
|
||||
let mut args = vec![
|
||||
"xlsx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
target.input_path.display().to_string(),
|
||||
"--sheet".to_string(),
|
||||
target.sheet_name.clone(),
|
||||
"--out".to_string(),
|
||||
target.output_path.display().to_string(),
|
||||
];
|
||||
if let Some(range) = &target.range {
|
||||
args.push("--range".to_string());
|
||||
args.push(range.clone());
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PresentationRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub slide_number: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SpreadsheetRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub sheet_name: String,
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactCommandOutput {
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl ArtifactCommandOutput {
|
||||
pub fn success(&self) -> bool {
|
||||
self.exit_code == Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactsError {
|
||||
#[error(transparent)]
|
||||
Runtime(#[from] ArtifactRuntimeError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("artifact command timed out after {timeout:?}")]
|
||||
TimedOut { timeout: Duration },
|
||||
}
|
||||
|
||||
fn build_wrapped_script(source: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"import {{ pathToFileURL }} from \"node:url\";\n",
|
||||
"const artifactTool = await import(pathToFileURL(process.env.CODEX_ARTIFACT_BUILD_ENTRYPOINT).href);\n",
|
||||
"globalThis.artifactTool = artifactTool;\n",
|
||||
"globalThis.artifacts = artifactTool;\n",
|
||||
"globalThis.codexArtifacts = artifactTool;\n",
|
||||
"for (const [name, value] of Object.entries(artifactTool)) {{\n",
|
||||
" if (name === \"default\" || Object.prototype.hasOwnProperty.call(globalThis, name)) {{\n",
|
||||
" continue;\n",
|
||||
" }}\n",
|
||||
" globalThis[name] = value;\n",
|
||||
"}}\n\n",
|
||||
"{}\n"
|
||||
),
|
||||
source
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
mut command: Command,
|
||||
execution_timeout: Duration,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let mut child = command.spawn().map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to spawn artifact command".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let mut stdout = child.stdout.take().ok_or_else(|| ArtifactsError::Io {
|
||||
context: "artifact command stdout was not captured".to_string(),
|
||||
source: std::io::Error::other("missing stdout pipe"),
|
||||
})?;
|
||||
let mut stderr = child.stderr.take().ok_or_else(|| ArtifactsError::Io {
|
||||
context: "artifact command stderr was not captured".to_string(),
|
||||
source: std::io::Error::other("missing stderr pipe"),
|
||||
})?;
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut bytes = Vec::new();
|
||||
stdout.read_to_end(&mut bytes).await.map(|_| bytes)
|
||||
});
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut bytes = Vec::new();
|
||||
stderr.read_to_end(&mut bytes).await.map(|_| bytes)
|
||||
});
|
||||
|
||||
let status = match timeout(execution_timeout, child.wait()).await {
|
||||
Ok(result) => result.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed while waiting for artifact command".to_string(),
|
||||
source,
|
||||
})?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Err(ArtifactsError::TimedOut {
|
||||
timeout: execution_timeout,
|
||||
});
|
||||
}
|
||||
};
|
||||
let stdout_bytes = stdout_task
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to join stdout reader".to_string(),
|
||||
source: std::io::Error::other(source.to_string()),
|
||||
})?
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to read artifact command stdout".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let stderr_bytes = stderr_task
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to join stderr reader".to_string(),
|
||||
source: std::io::Error::other(source.to_string()),
|
||||
})?
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to read artifact command stderr".to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(ArtifactCommandOutput {
|
||||
exit_code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use crate::ArtifactRuntimePlatform;
|
||||
#[cfg(unix)]
|
||||
use crate::ExtractedRuntimeManifest;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimeEntrypoints;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimePathEntry;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn wrapped_build_script_exposes_artifact_tool_surface() {
|
||||
let wrapped = build_wrapped_script("console.log(Object.keys(artifactTool).length);");
|
||||
assert!(wrapped.contains("const artifactTool = await import("));
|
||||
assert!(wrapped.contains("globalThis.artifactTool = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.artifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.codexArtifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("Object.entries(artifactTool)"));
|
||||
assert!(wrapped.contains("globalThis[name] = value;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Presentation(PresentationRenderTarget {
|
||||
input_path: PathBuf::from("deck.pptx"),
|
||||
output_path: PathBuf::from("slide.png"),
|
||||
slide_number: 2,
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"pptx",
|
||||
"render",
|
||||
"--in",
|
||||
"deck.pptx",
|
||||
"--slide",
|
||||
"2",
|
||||
"--out",
|
||||
"slide.png"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget {
|
||||
input_path: PathBuf::from("book.xlsx"),
|
||||
output_path: PathBuf::from("sheet.png"),
|
||||
sheet_name: "Summary".to_string(),
|
||||
range: Some("A1:C3".to_string()),
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"xlsx",
|
||||
"render",
|
||||
"--in",
|
||||
"book.xlsx",
|
||||
"--sheet",
|
||||
"Summary",
|
||||
"--out",
|
||||
"sheet.png",
|
||||
"--range",
|
||||
"A1:C3"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn execute_build_invokes_runtime_node_with_expected_environment() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let cwd = temp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let log_path = temp.path().join("build.log");
|
||||
let fake_node = temp.path().join("fake-node.sh");
|
||||
let build_entrypoint = temp.path().join("artifact_tool.mjs");
|
||||
let render_entrypoint = temp.path().join("render_cli.mjs");
|
||||
fs::write(
|
||||
&fake_node,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$1\" > \"{}\"\nprintf '%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
log_path.display(),
|
||||
log_path.display()
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::set_permissions(&fake_node, std::fs::Permissions::from_mode(0o755))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let runtime = InstalledArtifactRuntime::new(
|
||||
temp.path().join("runtime"),
|
||||
"0.1.0".to_string(),
|
||||
ArtifactRuntimePlatform::LinuxX64,
|
||||
sample_manifest("0.1.0"),
|
||||
fake_node.clone(),
|
||||
build_entrypoint.clone(),
|
||||
render_entrypoint,
|
||||
);
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
|
||||
let output = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: "console.log('hello');".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
env: BTreeMap::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(output.success());
|
||||
let logged = fs::read_to_string(&log_path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(logged.contains("artifact-build.mjs"));
|
||||
assert!(logged.contains(&build_entrypoint.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sample_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
23
codex-rs/artifacts/src/lib.rs
Normal file
23
codex-rs/artifacts/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
mod client;
|
||||
mod runtime;
|
||||
|
||||
pub use client::ArtifactBuildRequest;
|
||||
pub use client::ArtifactCommandOutput;
|
||||
pub use client::ArtifactRenderCommandRequest;
|
||||
pub use client::ArtifactRenderTarget;
|
||||
pub use client::ArtifactsClient;
|
||||
pub use client::ArtifactsError;
|
||||
pub use client::PresentationRenderTarget;
|
||||
pub use client::SpreadsheetRenderTarget;
|
||||
pub use runtime::ArtifactRuntimeError;
|
||||
pub use runtime::ArtifactRuntimeManager;
|
||||
pub use runtime::ArtifactRuntimeManagerConfig;
|
||||
pub use runtime::ArtifactRuntimePlatform;
|
||||
pub use runtime::ArtifactRuntimeReleaseLocator;
|
||||
pub use runtime::DEFAULT_CACHE_ROOT_RELATIVE;
|
||||
pub use runtime::DEFAULT_RELEASE_TAG_PREFIX;
|
||||
pub use runtime::ExtractedRuntimeManifest;
|
||||
pub use runtime::InstalledArtifactRuntime;
|
||||
pub use runtime::ReleaseManifest;
|
||||
pub use runtime::RuntimeEntrypoints;
|
||||
pub use runtime::RuntimePathEntry;
|
||||
522
codex-rs/artifacts/src/runtime.rs
Normal file
522
codex-rs/artifacts/src/runtime.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
use codex_package_manager::ManagedPackage;
|
||||
use codex_package_manager::PackageManager;
|
||||
use codex_package_manager::PackageManagerConfig;
|
||||
use codex_package_manager::PackageManagerError;
|
||||
pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform;
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v";
|
||||
pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeReleaseLocator {
|
||||
base_url: Url,
|
||||
runtime_version: String,
|
||||
release_tag_prefix: String,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeReleaseLocator {
|
||||
pub fn new(base_url: Url, runtime_version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
runtime_version: runtime_version.into(),
|
||||
release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into<String>) -> Self {
|
||||
self.release_tag_prefix = release_tag_prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &Url {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn release_tag(&self) -> String {
|
||||
format!("{}{}", self.release_tag_prefix, self.runtime_version)
|
||||
}
|
||||
|
||||
pub fn manifest_file_name(&self) -> String {
|
||||
format!("{}-manifest.json", self.release_tag())
|
||||
}
|
||||
|
||||
pub fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&self.manifest_file_name())
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeManagerConfig {
|
||||
package_manager: PackageManagerConfig<ArtifactRuntimePackage>,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManagerConfig {
|
||||
pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self {
|
||||
package_manager: PackageManagerConfig::new(
|
||||
codex_home,
|
||||
ArtifactRuntimePackage::new(release),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.package_manager = self.package_manager.with_cache_root(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.package_manager.cache_root()
|
||||
}
|
||||
|
||||
pub fn release(&self) -> &ArtifactRuntimeReleaseLocator {
|
||||
&self.package_manager.package().release
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> &Path {
|
||||
self.package_manager.codex_home()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRuntimeManager {
|
||||
package_manager: PackageManager<ArtifactRuntimePackage>,
|
||||
config: ArtifactRuntimeManagerConfig,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManager {
|
||||
pub fn new(config: ArtifactRuntimeManagerConfig) -> Self {
|
||||
let package_manager = PackageManager::new(config.package_manager.clone());
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self {
|
||||
let package_manager = PackageManager::with_client(config.package_manager.clone(), client);
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &ArtifactRuntimeManagerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub async fn resolve_cached(
|
||||
&self,
|
||||
) -> Result<Option<InstalledArtifactRuntime>, ArtifactRuntimeError> {
|
||||
self.package_manager.resolve_cached().await
|
||||
}
|
||||
|
||||
pub async fn ensure_installed(&self) -> Result<InstalledArtifactRuntime, ArtifactRuntimeError> {
|
||||
self.package_manager.ensure_installed().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArtifactRuntimePackage {
|
||||
release: ArtifactRuntimeReleaseLocator,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimePackage {
|
||||
fn new(release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self { release }
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedPackage for ArtifactRuntimePackage {
|
||||
type Error = ArtifactRuntimeError;
|
||||
type Installed = InstalledArtifactRuntime;
|
||||
type ReleaseManifest = ReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
DEFAULT_CACHE_ROOT_RELATIVE
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
self.release.runtime_version()
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.release.manifest_url()
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.release
|
||||
.base_url()
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.runtime_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
PackageManagerError::MissingPlatform(platform.as_str().to_string()).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
package.runtime_version()
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
InstalledArtifactRuntime::load(root_dir, platform)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ReleaseManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub release_tag: String,
|
||||
#[serde(default)]
|
||||
pub node_version: Option<String>,
|
||||
pub platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ExtractedRuntimeManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub node: RuntimePathEntry,
|
||||
pub entrypoints: RuntimeEntrypoints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimePathEntry {
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimeEntrypoints {
|
||||
pub build_js: RuntimePathEntry,
|
||||
pub render_cli: RuntimePathEntry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstalledArtifactRuntime {
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstalledArtifactRuntime {
|
||||
pub fn new(
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
root_dir,
|
||||
runtime_version,
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self, ArtifactRuntimeError> {
|
||||
let manifest_path = root_dir.join("manifest.json");
|
||||
let manifest_bytes =
|
||||
std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io {
|
||||
context: format!("failed to read {}", manifest_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let manifest = serde_json::from_slice::<ExtractedRuntimeManifest>(&manifest_bytes)
|
||||
.map_err(|source| ArtifactRuntimeError::InvalidManifest {
|
||||
path: manifest_path,
|
||||
source,
|
||||
})?;
|
||||
let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?;
|
||||
let build_js_path =
|
||||
resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?;
|
||||
let render_cli_path = resolve_relative_runtime_path(
|
||||
&root_dir,
|
||||
&manifest.entrypoints.render_cli.relative_path,
|
||||
)?;
|
||||
|
||||
Ok(Self::new(
|
||||
root_dir,
|
||||
manifest.runtime_version.clone(),
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn platform(&self) -> ArtifactRuntimePlatform {
|
||||
self.platform
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> &ExtractedRuntimeManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
pub fn node_path(&self) -> &Path {
|
||||
&self.node_path
|
||||
}
|
||||
|
||||
pub fn build_js_path(&self) -> &Path {
|
||||
&self.build_js_path
|
||||
}
|
||||
|
||||
pub fn render_cli_path(&self) -> &Path {
|
||||
&self.render_cli_path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactRuntimeError {
|
||||
#[error(transparent)]
|
||||
PackageManager(#[from] PackageManagerError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("invalid manifest at {path}")]
|
||||
InvalidManifest {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("runtime path `{0}` is invalid")]
|
||||
InvalidRuntimePath(String),
|
||||
}
|
||||
|
||||
fn resolve_relative_runtime_path(
|
||||
root_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, ArtifactRuntimeError> {
|
||||
let relative = Path::new(relative_path);
|
||||
if relative.as_os_str().is_empty() || relative.is_absolute() {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
}) {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root_dir.join(relative))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn release_locator_builds_manifest_url() {
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.1.0",
|
||||
);
|
||||
let url = locator
|
||||
.manifest_url()
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://example.test/releases/artifact-runtime-v0.1.0-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_runtime() {
|
||||
let server = MockServer::start().await;
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!(
|
||||
"artifact-runtime-v{runtime_version}-{}.zip",
|
||||
platform.as_str()
|
||||
);
|
||||
let archive_bytes = build_zip_archive(runtime_version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = ReleaseManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
release_tag: format!("artifact-runtime-v{runtime_version}"),
|
||||
node_version: Some("22.0.0".to_string()),
|
||||
platforms: BTreeMap::from([(
|
||||
platform.as_str().to_string(),
|
||||
PackageReleaseArchive {
|
||||
archive: archive_name.clone(),
|
||||
sha256: archive_sha,
|
||||
format: codex_package_manager::ArchiveFormat::Zip,
|
||||
size_bytes: Some(archive_bytes.len() as u64),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/artifact-runtime-v{runtime_version}-manifest.json"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")),
|
||||
runtime_version,
|
||||
);
|
||||
let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
locator,
|
||||
));
|
||||
|
||||
let runtime = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(
|
||||
runtime
|
||||
.build_js_path()
|
||||
.ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs"))
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.render_cli_path()
|
||||
.ends_with(Path::new("granola-render/dist/cli.mjs"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
let manifest = serde_json::to_vec(&sample_extracted_manifest(runtime_version))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(&manifest)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/node/bin/node", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/artifact-tool/dist/artifact_tool.mjs",
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/granola-render/dist/cli.mjs", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn sample_extracted_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user