use std::env; use std::fs::File; use std::io::Read as _; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, bail, Context, Result}; use blocking::unblock; use mime_guess::from_path; use warp_cli::artifact::UploadArtifactArgs; use super::common::parse_ambient_task_id; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::ServerAIConversationMetadata; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::server::server_api::ai::{ AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse, FileArtifactRecord, FileArtifactUploadTargetInfo, }; use crate::server::server_api::presigned_upload::upload_file_to_target; use crate::server::server_api::ServerApi; const MIME_SNIFF_BYTES: usize = 8 * 1024; const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID"; #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) struct FileArtifactUploadRequest { pub(crate) path: PathBuf, pub(crate) run_id: Option, pub(crate) conversation_id: Option, pub(crate) description: Option, } impl TryFrom for FileArtifactUploadRequest { type Error = anyhow::Error; fn try_from(value: UploadArtifactArgs) -> Result { let run_id = match value.run_id { Some(run_id) => Some(parse_run_id(&run_id, "Invalid run ID")?), None => None, }; Ok(Self { path: value.path, run_id, conversation_id: value.conversation_id.map(ServerConversationToken::new), description: value.description, }) } } #[derive(Debug, Clone)] pub(crate) struct CompletedFileArtifactUpload { pub(crate) artifact: FileArtifactRecord, pub(crate) size_bytes: i64, } #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) struct ResolvedUploadAssociation { conversation_id: Option, run_id: Option, pub(crate) ambient_task_id: AmbientAgentTaskId, } #[derive(Debug, Clone)] struct PreparedUploadArtifact { path: PathBuf, filepath: String, mime_type: String, file_size: u64, } impl PreparedUploadArtifact { fn from_path(path: PathBuf) -> Result { // `infer` only needs leading signature bytes, so avoid buffering the whole artifact // before we stream the file body to the upload target. let (file_size, mime_sniff_bytes) = file_size_and_prefix_for_path(&path, MIME_SNIFF_BYTES)?; Ok(Self { filepath: normalize_artifact_filepath(&path), mime_type: infer_mime_type(&path, &mime_sniff_bytes), file_size, path, }) } fn graphql_size_bytes(&self) -> Option { checked_graphql_size_bytes_for_upload(&self.path, self.file_size) } } pub(crate) struct FileArtifactUploader { ai_client: Arc, server_api: Arc, } impl FileArtifactUploader { pub(crate) fn new(ai_client: Arc, server_api: Arc) -> Self { Self { ai_client, server_api, } } pub(crate) async fn upload_with_association( &self, request: FileArtifactUploadRequest, association: ResolvedUploadAssociation, ) -> Result { let FileArtifactUploadRequest { path, description, .. } = request; let artifact = self.prepare_upload_artifact(path).await?; let create_response = self .create_upload_target(association, description, &artifact) .await?; let checksum = self .upload_artifact_bytes(&create_response.upload_target, &artifact) .await?; let uploaded_artifact = self .confirm_upload(create_response.artifact.artifact_uid, checksum) .await?; let size_bytes = i64::try_from(artifact.file_size) .context("Artifact file size exceeds supported range")?; Ok(CompletedFileArtifactUpload { artifact: uploaded_artifact, size_bytes, }) } async fn prepare_upload_artifact(&self, path: PathBuf) -> Result { unblock(move || PreparedUploadArtifact::from_path(path)).await } async fn create_upload_target( &self, association: ResolvedUploadAssociation, description: Option, artifact: &PreparedUploadArtifact, ) -> Result { self.ai_client .create_file_artifact_upload_target(CreateFileArtifactUploadRequest { conversation_id: association .conversation_id .as_ref() .map(|token| token.as_str().to_string()), run_id: association.run_id.as_ref().map(ToString::to_string), filepath: artifact.filepath.clone(), description, mime_type: Some(artifact.mime_type.clone()), size_bytes: artifact.graphql_size_bytes(), }) .await .context("Failed to create file artifact upload target") } async fn upload_artifact_bytes( &self, target: &FileArtifactUploadTargetInfo, artifact: &PreparedUploadArtifact, ) -> Result { upload_file_to_target( self.server_api.http_client(), target, &artifact.path, artifact.file_size, ) .await } async fn confirm_upload( &self, artifact_uid: String, checksum: String, ) -> Result { self.ai_client .confirm_file_artifact_upload(artifact_uid, checksum) .await .context("Failed to confirm file artifact upload") } pub(crate) async fn resolve_upload_association( &self, request: &FileArtifactUploadRequest, ) -> Result { let conversation_task_id = match (request.run_id.as_ref(), request.conversation_id.as_ref()) { // we were given a conversation id, so we need to resolve the task id from the conversation via the api (None, Some(conversation_id)) => { Some(self.resolve_conversation_task_id(conversation_id).await) } _ => None, }; resolve_upload_association_from_sources( request.run_id, request.conversation_id.clone(), conversation_task_id, load_env_run_id()?, ) } async fn resolve_conversation_task_id( &self, conversation_id: &ServerConversationToken, ) -> Result { let metadata = self .ai_client .list_ai_conversation_metadata(Some(vec![conversation_id.as_str().to_string()])) .await .with_context(|| { format!( "Failed to load conversation '{}' to resolve artifact upload headers", conversation_id.as_str() ) })?; let metadata = single_conversation_metadata(conversation_id.as_str(), metadata) .with_context(|| { format!( "Failed to load conversation '{}' to resolve artifact upload headers", conversation_id.as_str() ) })?; ambient_task_id_from_conversation_metadata(conversation_id.as_str(), metadata) } } fn normalize_artifact_filepath(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } fn infer_mime_type(path: &Path, file_bytes: &[u8]) -> String { infer::get(file_bytes) .map(|kind| kind.mime_type().to_string()) .unwrap_or_else(|| from_path(path).first_or_octet_stream().to_string()) } fn file_size_and_prefix_for_path(path: &Path, max_bytes: usize) -> Result<(u64, Vec)> { let mut file = File::open(path) .with_context(|| format!("Failed to open artifact file '{}'", path.display()))?; let file_size = file .metadata() .with_context(|| format!("Failed to stat artifact file '{}'", path.display()))? .len(); let mut bytes = vec![0; max_bytes]; let bytes_read = file .read(&mut bytes) .with_context(|| format!("Failed to read artifact file '{}'", path.display()))?; bytes.truncate(bytes_read); Ok((file_size, bytes)) } fn checked_graphql_size_bytes_for_upload(path: &Path, size_bytes: u64) -> Option { let graphql_size_bytes = i32::try_from(size_bytes).ok(); if graphql_size_bytes.is_none() { // The backing upload can handle large files, but the GraphQL field is still `Int`. // Dropping `size_bytes` preserves the upload request instead of failing on conversion. log::warn!( "Artifact file '{}' is {} bytes, which exceeds the GraphQL size_bytes limit of {} bytes; omitting size_bytes from the upload target request", path.display(), size_bytes, i32::MAX, ); } graphql_size_bytes } fn single_conversation_metadata( conversation_id: &str, mut metadata: Vec, ) -> Result { match metadata.len() { 0 => bail!("Conversation not found"), 1 => Ok(metadata.pop().expect("metadata length checked")), _ => bail!("Multiple conversations found for '{conversation_id}'"), } } fn ambient_task_id_from_conversation_metadata( conversation_id: &str, metadata: ServerAIConversationMetadata, ) -> Result { metadata.ambient_agent_task_id.ok_or_else(|| { anyhow!("Conversation '{conversation_id}' is not backed by a cloud agent task") }) } fn parse_run_id(run_id: &str, error_prefix: &str) -> Result { parse_ambient_task_id(run_id, error_prefix) } fn load_env_run_id() -> Result> { match env::var(OZ_RUN_ID_ENV_VAR) { Ok(run_id) => Ok(Some(run_id)), Err(env::VarError::NotPresent) => Ok(None), Err(env::VarError::NotUnicode(_)) => Err(anyhow!( "{OZ_RUN_ID_ENV_VAR} is set but is not valid Unicode" )), } } fn resolve_env_run_id(env_run_id: Option) -> Result { let Some(run_id) = env_run_id else { bail!("{OZ_RUN_ID_ENV_VAR} is not set"); }; parse_run_id(&run_id, "Invalid OZ_RUN_ID") } fn resolve_upload_association_from_sources( explicit_run_id: Option, explicit_conversation_id: Option, conversation_task_id: Option>, env_run_id: Option, ) -> Result { // Precedence is deliberate: // 1. An explicit run ID is authoritative and must not silently fall back. // 2. A conversation ID stays attached to the artifact even if we have to borrow the ambient // task ID from `OZ_RUN_ID` because the conversation lacks cloud-task metadata. // 3. `OZ_RUN_ID` becomes the sole source of truth only when the caller supplied nothing else. if let Some(run_id) = explicit_run_id { let ambient_task_id = run_id; return Ok(ResolvedUploadAssociation { conversation_id: None, run_id: Some(run_id), ambient_task_id, }); } if let Some(conversation_id) = explicit_conversation_id { match conversation_task_id .ok_or_else(|| anyhow!("conversation resolution should be provided"))? { Ok(ambient_task_id) => { return Ok(ResolvedUploadAssociation { conversation_id: Some(conversation_id), run_id: None, ambient_task_id, }); } Err(conversation_err) => { let env_err = match resolve_env_run_id(env_run_id) { Ok(ambient_task_id) => { log::warn!( "Conversation '{}' task resolution failed ({conversation_err}); falling back to {OZ_RUN_ID_ENV_VAR} for ambient task context", conversation_id.as_str() ); return Ok(ResolvedUploadAssociation { conversation_id: Some(conversation_id), run_id: None, ambient_task_id, }); } Err(env_err) => env_err, }; return Err(anyhow!( "Failed to resolve artifact upload association for conversation '{}': {conversation_err}; also failed to use {OZ_RUN_ID_ENV_VAR}: {env_err}", conversation_id.as_str() )); } } } let ambient_task_id = resolve_env_run_id(env_run_id).map_err(|env_err| { anyhow!( "Failed to resolve artifact upload association: no usable --run-id or --conversation-id was provided, and {OZ_RUN_ID_ENV_VAR}: {env_err}" ) })?; Ok(ResolvedUploadAssociation { conversation_id: None, run_id: Some(ambient_task_id), ambient_task_id, }) } #[cfg(test)] #[path = "artifact_upload_tests.rs"] mod tests;