blob: 5f45bdfbe9d48c5f5548e4d190d3eff2f416684f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::*;
use maka_agent_facts::artifact::Artifact;
use maka_agent_facts::artifact::ArtifactKind;
use maka_agent_facts::artifact::ArtifactSource;
use maka_agent_facts::artifact::content_digest;
use maka_agent_facts::artifact::normalize_name;
use maka_agent_facts::artifact::upload_artifact_id;
use maka_agent_facts::attachment::AttachmentKind;
use maka_agent_facts::attachment::AttachmentRef;
use maka_agent_facts::attachment::StorageRef;
use maka_agent_facts::attachment::sniff_binary_mime;
use maka_capabilities::call::ReadResource;
use maka_capabilities::call::ResourceEncoding;
use maka_host_runtime::application::Context;
impl Owner {
pub async fn upload_attachment(
self: &Arc<Self>,
input: UploadAttachment,
context: Context,
) -> Result<AttachmentUploadResult, String> {
let operation = context.operation().ok_or("agent_mutation_required")?;
let identity = self.operation_id(operation);
let resource = context
.attachments()
.get("file")
.ok_or("agent_attachment_required")?
.clone();
let mut ticket = context
.scope()
.resources
.reserve()
.map_err(|e| e.to_string())?;
ticket.start();
let owner = self.clone();
// This original Scope owns the entire worker through the SQL commit. Losing
// the caller never reports physical cleanup before that worker finishes.
tokio::spawn(async move {
let result = async {
let runs = owner.runs.lock().await;
if runs.closing || context.scope().cancellation.is_cancelled() {
return Ok(AttachmentUploadResult::Rejected {
reason: AttachmentRejection::Closed,
});
}
if owner.session(&input.session_id).await?.archived {
return Ok(AttachmentUploadResult::Rejected {
reason: AttachmentRejection::Archived,
});
}
let cancellation = context.scope().cancellation.clone();
let material = tokio::task::spawn_blocking(move || {
let bytes = read(&*resource, &input, &cancellation)?;
let mime = mime(&input.name, &bytes);
let record = Artifact {
id: upload_artifact_id(&input.session_id, &identity),
session_id: input.session_id,
turn_id: format!("upload_{identity}"),
created_at: now(),
name: normalize_name(&input.name),
kind: if mime.starts_with("image/") {
ArtifactKind::Image
} else if mime == "application/pdf" {
ArtifactKind::Pdf
} else {
ArtifactKind::File
},
size_bytes: bytes.len() as u64,
mime_type: Some(mime.into()),
source: ArtifactSource::UserUpload,
summary: Some(input.digest),
};
Ok::<_, String>((record, bytes))
})
.await
.map_err(|_| "agent_attachment_worker_failed")?;
let Ok((record, payload)) = material else {
return Ok(AttachmentUploadResult::Rejected {
reason: AttachmentRejection::InvalidFile,
});
};
if context.scope().cancellation.is_cancelled() {
return Ok(AttachmentUploadResult::Rejected {
reason: AttachmentRejection::Closed,
});
}
let record = owner
.store
.commit_artifact(record, payload)
.await
.map_err(|_| "agent_attachment_commit_failed")?;
Ok(AttachmentUploadResult::Uploaded {
attachment: Attachment {
session_id: record.session_id,
id: record.id,
name: record.name,
mime_type: record.mime_type.expect("upload MIME"),
bytes: record.size_bytes,
},
})
}
.await;
drop(context);
ticket.complete(Ok(()));
result
})
.await
.map_err(|_| "agent_attachment_worker_failed")?
}
pub(super) async fn attachment_refs(
&self,
session: &str,
ids: Vec<String>,
) -> Result<Option<Vec<AttachmentRef>>, String> {
if ids.is_empty() {
return Ok(None);
}
let mut seen = std::collections::BTreeSet::new();
let mut attachments = Vec::with_capacity(ids.len());
for id in ids {
if maka_agent_facts::interaction::entity_id(&id).is_err() || !seen.insert(id.clone()) {
return Err("agent_attachments_invalid".into());
}
let record = self
.store
.get_artifact(session, &id)
.await
.map_err(|_| "agent_attachment_unavailable")?
.record
.filter(|record| {
record.source == ArtifactSource::UserUpload && record.session_id == session
})
.ok_or("agent_attachment_unavailable")?;
let mime_type = record
.mime_type
.clone()
.ok_or("agent_attachment_unavailable")?;
let attachment = AttachmentRef {
kind: AttachmentKind::from_metadata(&mime_type, &record.name),
name: record.name.clone(),
mime_type,
bytes: record.size_bytes,
storage_ref: StorageRef::SessionFile {
session_id: session.into(),
relative_path: id,
},
};
record
.validate_attachment(&attachment)
.map_err(|_| "agent_attachment_invalid")?;
attachments.push(attachment);
}
Ok(Some(attachments))
}
}
fn read(
resource: &dyn ReadResource,
input: &UploadAttachment,
cancellation: &CancellationToken,
) -> Result<Vec<u8>, String> {
if resource.resource_type() != maka_plugin_core::contract::binary_resource_type()
|| resource.encoding() != ResourceEncoding::Bytes
|| resource.bytes() != input.bytes
|| input.name.is_empty()
|| input.name.chars().any(char::is_control)
{
return Err("agent_attachment_invalid".into());
}
let mut bytes = Vec::with_capacity(input.bytes);
for offset in (0..input.bytes).step_by(64 * 1024) {
if cancellation.is_cancelled() {
return Err("agent_attachment_cancelled".into());
}
let end = (offset + 64 * 1024).min(input.bytes);
let chunk = resource.read(offset..end)?;
if chunk.len() != end - offset {
return Err("agent_attachment_invalid".into());
}
bytes.extend_from_slice(&chunk);
}
if content_digest(&bytes) != input.digest {
return Err("agent_attachment_digest_mismatch".into());
}
Ok(bytes)
}
fn mime(name: &str, bytes: &[u8]) -> &'static str {
if let Some(mime) = sniff_binary_mime(bytes) {
return mime;
}
let text = std::str::from_utf8(bytes).is_ok() && !bytes.contains(&0);
match name
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"json" if text => "application/json",
"csv" if text => "text/csv",
"md" if text => "text/markdown",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ if text => "text/plain",
_ => "application/octet-stream",
}
}