387 lines
13 KiB
Rust
387 lines
13 KiB
Rust
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<AmbientAgentTaskId>,
|
|
pub(crate) conversation_id: Option<ServerConversationToken>,
|
|
pub(crate) description: Option<String>,
|
|
}
|
|
|
|
impl TryFrom<UploadArtifactArgs> for FileArtifactUploadRequest {
|
|
type Error = anyhow::Error;
|
|
|
|
fn try_from(value: UploadArtifactArgs) -> Result<Self> {
|
|
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<ServerConversationToken>,
|
|
run_id: Option<AmbientAgentTaskId>,
|
|
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<Self> {
|
|
// `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<i32> {
|
|
checked_graphql_size_bytes_for_upload(&self.path, self.file_size)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct FileArtifactUploader {
|
|
ai_client: Arc<dyn AIClient>,
|
|
server_api: Arc<ServerApi>,
|
|
}
|
|
|
|
impl FileArtifactUploader {
|
|
pub(crate) fn new(ai_client: Arc<dyn AIClient>, server_api: Arc<ServerApi>) -> Self {
|
|
Self {
|
|
ai_client,
|
|
server_api,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn upload_with_association(
|
|
&self,
|
|
request: FileArtifactUploadRequest,
|
|
association: ResolvedUploadAssociation,
|
|
) -> Result<CompletedFileArtifactUpload> {
|
|
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<PreparedUploadArtifact> {
|
|
unblock(move || PreparedUploadArtifact::from_path(path)).await
|
|
}
|
|
|
|
async fn create_upload_target(
|
|
&self,
|
|
association: ResolvedUploadAssociation,
|
|
description: Option<String>,
|
|
artifact: &PreparedUploadArtifact,
|
|
) -> Result<CreateFileArtifactUploadResponse> {
|
|
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<String> {
|
|
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<FileArtifactRecord> {
|
|
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<ResolvedUploadAssociation> {
|
|
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<AmbientAgentTaskId> {
|
|
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<u8>)> {
|
|
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<i32> {
|
|
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<ServerAIConversationMetadata>,
|
|
) -> Result<ServerAIConversationMetadata> {
|
|
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<AmbientAgentTaskId> {
|
|
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<AmbientAgentTaskId> {
|
|
parse_ambient_task_id(run_id, error_prefix)
|
|
}
|
|
|
|
fn load_env_run_id() -> Result<Option<String>> {
|
|
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<String>) -> Result<AmbientAgentTaskId> {
|
|
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<AmbientAgentTaskId>,
|
|
explicit_conversation_id: Option<ServerConversationToken>,
|
|
conversation_task_id: Option<Result<AmbientAgentTaskId>>,
|
|
env_run_id: Option<String>,
|
|
) -> Result<ResolvedUploadAssociation> {
|
|
// 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;
|