blob: 9a602e04986a8d302556228789b7d951a43a79fd [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.
*/
mod copy;
pub(crate) mod history;
mod read;
mod records;
pub(crate) mod references;
use crate::{EventLog, StoreError};
use maka_agent_facts::artifact::Artifact;
use maka_agent_facts::artifact::ArtifactSource;
use maka_agent_facts::artifact::content_digest;
use records::{advance, decode, invalid, read_record, require_session};
use sqlx::Connection;
#[derive(Debug, PartialEq, Eq)]
pub enum ArtifactDeletion {
Deleted,
NotFound,
Protected,
}
#[derive(Debug)]
pub struct ArtifactPage {
pub revision: String,
pub records: Vec<Artifact>,
pub total: u64,
}
#[derive(Debug)]
pub struct ArtifactEntry {
pub revision: String,
pub record: Option<Artifact>,
}
/// Trusted read scope; execution preserves the original Session + Turn predicate.
pub enum ArtifactReadScope<'a> {
Session(&'a str),
Execution(&'a maka_agent_facts::event::Invocation),
}
#[derive(Debug)]
pub struct ArtifactChunk {
pub total_bytes: u64,
pub bytes: Vec<u8>,
}
impl EventLog {
/// Immutable payload + metadata in one transaction. Exact retries retain the
/// first creation time; all other metadata and payload bytes must agree.
pub async fn commit_artifact(
&self,
artifact: Artifact,
bytes: impl AsRef<[u8]> + Send + 'static,
) -> Result<Artifact, StoreError> {
self.validate_root()?;
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
// The job retains the payload owner, including any memory reservation,
// independently of its caller. Hashing runs on the database thread.
let artifact = commit_in_transaction(&mut tx, artifact, bytes.as_ref()).await?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(artifact)
})
})
.await
}
/// Runtime-owned evidence cannot be deleted independently of its workflow.
pub async fn delete_user_artifact(
&self,
session_id: &str,
artifact_id: &str,
) -> Result<ArtifactDeletion, StoreError> {
self.validate_root()?;
records::ids(session_id, Some(artifact_id))?;
let (session_id, artifact_id) = (session_id.to_owned(), artifact_id.to_owned());
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
require_session(&mut tx, &session_id).await?;
let Some((artifact, _)) =
read_record(&mut tx, &session_id, &artifact_id).await?
else {
return Ok(ArtifactDeletion::NotFound);
};
let retained: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_history_artifacts WHERE session_id = ? AND artifact_id = ?)",
).bind(&session_id).bind(&artifact_id).fetch_one(&mut *tx).await?;
if !artifact.source.user_deletable() || retained
|| sqlx::query_scalar::<_, bool>("SELECT canonical_at IS NOT NULL FROM artifacts WHERE session_id = ? AND id = ?")
.bind(&session_id).bind(&artifact_id).fetch_one(&mut *tx).await?
{
return Ok(ArtifactDeletion::Protected);
}
sqlx::query("DELETE FROM artifacts WHERE session_id = ? AND id = ?")
.bind(&session_id)
.bind(&artifact_id)
.execute(&mut *tx)
.await?;
advance(&mut tx, &session_id).await?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(ArtifactDeletion::Deleted)
})
})
.await
}
}
/// Shared immutable artifact write; the caller owns the enclosing transaction.
pub(crate) async fn commit_in_transaction(
connection: &mut sqlx::SqliteConnection,
mut artifact: Artifact,
bytes: &[u8],
) -> Result<Artifact, StoreError> {
let digest = content_digest(bytes);
let encoded = validate_material(&artifact, bytes.len() as u64, &digest)?;
require_session(connection, &artifact.session_id).await?;
if let Some((existing, stored_digest)) =
read_record(connection, &artifact.session_id, &artifact.id).await?
{
if artifact.source == ArtifactSource::ToolResultProjection
&& artifact.created_at != existing.created_at
{
return Err(StoreError::ArtifactConflict);
}
artifact.created_at = existing.created_at;
if artifact != existing || digest != stored_digest {
return Err(StoreError::ArtifactConflict);
}
verify_payload(connection, &artifact, bytes).await?;
return Ok(existing);
}
sqlx::query(
"INSERT INTO artifacts (session_id, id, created_at, record_json, content_sha256, payload)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&artifact.session_id)
.bind(&artifact.id)
.bind(artifact.created_at as i64)
.bind(encoded)
.bind(digest)
.bind(bytes)
.execute(&mut *connection)
.await?;
advance(connection, &artifact.session_id).await?;
Ok(artifact)
}
/// Validate immutable metadata against already checked payload length and digest.
pub(crate) fn validate_material(
artifact: &Artifact,
bytes: u64,
digest: &str,
) -> Result<String, StoreError> {
artifact.validate().map_err(invalid)?;
if artifact.size_bytes != bytes {
return Err(invalid("Artifact payload size mismatch"));
}
let encoded = serde_json::to_string(artifact)?;
if artifact.source == ArtifactSource::UserUpload && artifact.summary.as_deref() != Some(digest)
{
return Err(StoreError::ArtifactConflict);
}
Ok(encoded)
}
pub(crate) async fn verify_projection_replay(
connection: &mut sqlx::SqliteConnection,
artifact: &Artifact,
bytes: &[u8],
) -> Result<(), StoreError> {
let Some((existing, digest)) =
read_record(connection, &artifact.session_id, &artifact.id).await?
else {
return Err(StoreError::EventConflict);
};
if &existing != artifact || digest != content_digest(bytes) {
return Err(StoreError::EventConflict);
}
verify_payload(connection, artifact, bytes).await
}
async fn verify_payload(
connection: &mut sqlx::SqliteConnection,
artifact: &Artifact,
bytes: &[u8],
) -> Result<(), StoreError> {
let matches: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM artifacts WHERE session_id = ? AND id = ? AND payload = ?)",
)
.bind(&artifact.session_id)
.bind(&artifact.id)
.bind(bytes)
.fetch_one(connection)
.await?;
if !matches {
return Err(StoreError::ArtifactConflict);
}
Ok(())
}
/// Bounded immutable encoded image material; no filesystem path or bearer handle.
#[derive(Debug)]
pub struct ImageChunk {
pub total_bytes: u64,
pub mime_type: String,
pub digest: String,
pub bytes: Vec<u8>,
}
pub(crate) async fn read_image(
connection: &mut sqlx::SqliteConnection,
session: &str,
id: &str,
mime: &str,
offset: u64,
limit: usize,
row_event: u64,
) -> Result<ImageChunk, StoreError> {
records::ids(session, Some(id))?;
require_session(connection, session).await?;
let (artifact, digest) = read_record(connection, session, id)
.await?
.ok_or_else(|| invalid("tool image artifact not found"))?;
let canonical_at: Option<i64> =
sqlx::query_scalar("SELECT canonical_at FROM artifacts WHERE session_id = ? AND id = ?")
.bind(session)
.bind(id)
.fetch_one(&mut *connection)
.await?;
if !canonical_at.is_some_and(|at| at > 0 && at as u64 <= row_event) {
return Err(invalid(
"tool image bytes were not frozen at their canonical row",
));
}
image_chunk(connection, &artifact, digest, mime, offset, limit).await
}
pub(crate) async fn read_attachment_image(
connection: &mut sqlx::SqliteConnection,
session: &str,
id: &str,
attachment: Option<&maka_agent_facts::attachment::AttachmentRef>,
offset: u64,
limit: usize,
) -> Result<ImageChunk, StoreError> {
records::ids(session, Some(id))?;
require_session(connection, session).await?;
let (artifact, digest) = read_record(connection, session, id)
.await?
.ok_or_else(|| invalid("uploaded image artifact not found"))?;
if artifact.source != ArtifactSource::UserUpload || artifact.summary.as_ref() != Some(&digest) {
return Err(invalid("image is not an immutable user upload"));
}
if let Some(attachment) = attachment {
artifact.validate_attachment(attachment).map_err(invalid)?;
}
let mime = artifact
.mime_type
.as_deref()
.ok_or_else(|| invalid("uploaded image has no MIME type"))?;
image_chunk(connection, &artifact, digest, mime, offset, limit).await
}
async fn image_chunk(
connection: &mut sqlx::SqliteConnection,
artifact: &Artifact,
digest: String,
mime: &str,
offset: u64,
limit: usize,
) -> Result<ImageChunk, StoreError> {
if artifact.kind != maka_agent_facts::artifact::ArtifactKind::Image
|| artifact.mime_type.as_deref() != Some(mime)
|| !matches!(
mime,
"image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/avif"
)
|| artifact.size_bytes == 0
|| !digest.strip_prefix("sha256:").is_some_and(|value| {
value.len() == 64
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
{
return Err(invalid("invalid retained image metadata"));
}
if offset > artifact.size_bytes {
return Err(StoreError::ArtifactOffset);
}
let (total, bytes): (i64, Vec<u8>) = sqlx::query_as(
"SELECT length(payload), substr(payload, ?, ?) FROM artifacts WHERE session_id = ? AND id = ?"
).bind(offset as i64 + 1).bind(limit as i64).bind(&artifact.session_id).bind(&artifact.id).fetch_one(&mut *connection).await?;
if total as u64 != artifact.size_bytes
|| bytes.len() as u64 != (artifact.size_bytes - offset).min(limit as u64)
{
return Err(invalid("image artifact size mismatch"));
}
Ok(ImageChunk {
total_bytes: artifact.size_bytes,
mime_type: mime.to_owned(),
digest,
bytes,
})
}
/// Consume artifact-backed media in the same transaction as original settlement.
/// The private artifact owner field survives pruning and is inherited by copies;
/// a later copy/consumer cannot retroactively establish an earlier byte identity.
pub(crate) async fn freeze_tool_media(
connection: &mut sqlx::SqliteConnection,
event: &maka_agent_facts::event::RuntimeEvent,
sequence: u64,
) -> Result<(), StoreError> {
use maka_agent_facts::attachment::StorageRef;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::ToolOutcome;
use maka_agent_facts::tool_output::DurableToolProjection;
let Fact::ToolSettled {
outcome:
ToolOutcome::Succeeded {
model_projection: DurableToolProjection::Content { parts },
..
},
..
} = &event.fact
else {
return Ok(());
};
for part in parts {
let Some((
StorageRef::SessionFile {
session_id,
relative_path,
},
_,
)) = part.media()
else {
continue;
};
if session_id != &event.invocation.session_id {
continue;
}
sqlx::query("UPDATE artifacts SET canonical_at=COALESCE(canonical_at, ?) WHERE session_id=? AND id=?")
.bind(sequence as i64).bind(session_id).bind(relative_path).execute(&mut *connection).await?;
// Unavailable borrowed media does not revoke the successful effect or
// its exact raw result. No freeze/read grant is created for a missing
// artifact; replay already renders unavailable media explicitly.
}
Ok(())
}