use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::error::{Result, ShoreError};
use crate::model::{ObjectId, id_prefix};
use crate::session::body_artifact::{body_artifact_field, note_body_content_hash_from_path};
use crate::session::event::{EventType, ShoreEvent, WorkObjectProposal, WorkObjectProposedPayload};
use crate::session::object_artifact::{
decode_and_validate_object_artifact, read_bound_object_artifact_bytes,
};
use crate::session::store::content::ContentArtifacts;
use crate::session::store::resolution::{
prepare_write_landing, resolve_read_store, resolve_write_store,
};
use crate::storage::{CreateOutcome, LocalStorage};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ArtifactKind {
Object,
Body,
}
#[derive(Clone, Eq, PartialEq)]
pub struct ArtifactRef {
locator: ArtifactLocator,
content_hash: String,
}
impl ArtifactRef {
pub fn kind(&self) -> ArtifactKind {
match self.locator {
ArtifactLocator::Object { .. } => ArtifactKind::Object,
ArtifactLocator::Body { .. } => ArtifactKind::Body,
}
}
pub fn content_hash(&self) -> &str {
&self.content_hash
}
}
impl fmt::Debug for ArtifactRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ArtifactRef")
.field("kind", &self.kind())
.field("content_hash", &self.content_hash)
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
enum ArtifactLocator {
Object { object_id: ObjectId },
Body { relative_path: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ImportArtifactOptions {
repo: PathBuf,
artifact: ArtifactRef,
bytes: Vec<u8>,
}
impl ImportArtifactOptions {
pub fn new(repo: impl AsRef<Path>, artifact: ArtifactRef, bytes: Vec<u8>) -> Self {
Self {
repo: repo.as_ref().to_path_buf(),
artifact,
bytes,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImportArtifactOutcome {
Created,
Existing,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ImportArtifactResult {
pub artifact: ArtifactRef,
pub outcome: ImportArtifactOutcome,
}
pub fn referenced_artifacts(events: &[ShoreEvent]) -> Result<Vec<ArtifactRef>> {
let mut refs = BTreeMap::<String, ArtifactRef>::new();
for event in events {
referenced_artifacts_for_event(event, &mut refs)?;
}
Ok(refs.into_values().collect())
}
pub fn export_artifact(repo: impl AsRef<Path>, artifact: &ArtifactRef) -> Result<Vec<u8>> {
match &artifact.locator {
ArtifactLocator::Object { object_id } => {
let bytes = read_bound_object_artifact_bytes(repo, object_id, &artifact.content_hash)?;
let stored = decode_and_validate_object_artifact(&bytes)?;
if stored.content_hash != artifact.content_hash {
return Err(ShoreError::Message(format!(
"object artifact content hash mismatch for {}",
artifact.content_hash
)));
}
Ok(bytes)
}
ArtifactLocator::Body { relative_path } => {
let read_store = resolve_read_store(repo.as_ref())?;
ContentArtifacts::from_backend(read_store.backend())
.read_note_body_bytes(relative_path, &artifact.content_hash)
}
}
}
pub fn import_artifact(options: ImportArtifactOptions) -> Result<ImportArtifactResult> {
let write_store = resolve_write_store(&options.repo)?;
let storage = LocalStorage::new(write_store.store_dir());
prepare_write_landing(&write_store, &storage)?;
let content = ContentArtifacts::from_backend(write_store.backend());
let outcome = match &options.artifact.locator {
ArtifactLocator::Object { object_id } => {
content.import_object(object_id, &options.artifact.content_hash, &options.bytes)?
}
ArtifactLocator::Body { relative_path } => content.import_body(
relative_path,
&options.artifact.content_hash,
&options.bytes,
)?,
};
Ok(ImportArtifactResult {
artifact: options.artifact,
outcome: match outcome {
CreateOutcome::Created => ImportArtifactOutcome::Created,
CreateOutcome::AlreadyExists => ImportArtifactOutcome::Existing,
},
})
}
fn referenced_artifacts_for_event(
event: &ShoreEvent,
refs: &mut BTreeMap<String, ArtifactRef>,
) -> Result<()> {
if event.event_type == EventType::WorkObjectProposed {
let payload: WorkObjectProposedPayload = serde_json::from_value(event.payload.clone())?;
if let WorkObjectProposal::Revision {
revision,
object_artifact_content_hash,
..
} = payload.work_object
{
insert_artifact_ref(
refs,
format!(
"{}:{object_artifact_content_hash}",
id_prefix::ARTIFACT_OBJECT
),
ArtifactRef {
locator: ArtifactLocator::Object {
object_id: revision.object_id,
},
content_hash: object_artifact_content_hash,
},
)?;
}
return Ok(());
}
if let Some(field) = body_artifact_field(event.event_type) {
let path = event
.payload
.get(field.payload_field())
.and_then(|value| value.as_str());
return insert_body_ref(refs, path);
}
Ok(())
}
fn insert_body_ref(
refs: &mut BTreeMap<String, ArtifactRef>,
relative_path: Option<&str>,
) -> Result<()> {
let Some(relative_path) = relative_path else {
return Ok(());
};
let content_hash = note_body_content_hash_from_path(relative_path)?;
insert_artifact_ref(
refs,
format!("{}:{content_hash}", id_prefix::ARTIFACT_BODY),
ArtifactRef {
locator: ArtifactLocator::Body {
relative_path: relative_path.to_owned(),
},
content_hash,
},
)
}
fn insert_artifact_ref(
refs: &mut BTreeMap<String, ArtifactRef>,
key: String,
artifact: ArtifactRef,
) -> Result<()> {
if let Some(existing) = refs.get(&key) {
if existing == &artifact {
return Ok(());
}
return Err(ShoreError::Message(format!(
"conflicting artifact reference for {}",
artifact.content_hash
)));
}
refs.insert(key, artifact);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
JournalId, RevisionId, TrackId, ValidationCheckId, ValidationStatus, ValidationTarget,
ValidationTrigger,
};
use crate::session::event::{EventTarget, EventType, ValidationCheckRecordedPayload, Writer};
#[test]
fn referenced_artifacts_includes_validation_summary_body() {
let hash = "a".repeat(64);
let event = validation_event_with_summary_path(&format!("artifacts/notes/{hash}.json"));
let refs = referenced_artifacts(&[event]).unwrap();
assert!(refs.iter().any(|artifact| {
artifact.kind() == ArtifactKind::Body
&& artifact.content_hash() == format!("sha256:{hash}")
}));
}
#[test]
fn every_registry_body_family_yields_a_body_ref() {
let hash = "b".repeat(64);
let path = format!("artifacts/notes/{hash}.json");
for event_type in EventType::ALL {
let Some(field) = body_artifact_field(event_type) else {
continue;
};
let field_name = field.payload_field();
let mut event = base_event();
event.event_type = event_type;
event.payload = serde_json::json!({ field_name: path });
let refs = referenced_artifacts(&[event]).unwrap();
assert!(
refs.iter().any(|a| a.kind() == ArtifactKind::Body
&& a.content_hash() == format!("sha256:{hash}")),
"path 1 dropped the body artifact for {event_type:?}"
);
}
}
#[test]
fn non_body_families_yield_no_body_ref() {
for event_type in EventType::ALL {
if body_artifact_field(event_type).is_some()
|| event_type == EventType::WorkObjectProposed
{
continue; }
let mut event = base_event();
event.event_type = event_type;
event.payload = serde_json::json!({ "bodyArtifactPath": "artifacts/notes/x.json" });
let refs = referenced_artifacts(&[event]).unwrap();
assert!(
refs.iter().all(|a| a.kind() != ArtifactKind::Body),
"path 1 spuriously enumerated a body artifact for non-body {event_type:?}"
);
}
}
#[test]
fn malformed_work_object_proposed_payload_still_errors() {
let mut event = base_event();
event.event_type = EventType::WorkObjectProposed;
event.payload = serde_json::json!({ "workObject": "not-an-object" });
assert!(referenced_artifacts(&[event]).is_err());
}
fn base_event() -> ShoreEvent {
validation_event_with_summary_path("artifacts/notes/placeholder.json")
}
fn validation_event_with_summary_path(path: &str) -> ShoreEvent {
let revision_id = RevisionId::new("review-unit:sha256:one");
let target = EventTarget::for_revision(
JournalId::new("journal:default"),
revision_id.clone(),
Some(TrackId::new("agent:codex")),
)
.unwrap();
ShoreEvent::new(
EventType::ValidationCheckRecorded,
"validation_check_recorded:one",
target,
Writer::shore_local("0.1.0"),
ValidationCheckRecordedPayload {
validation_check_id: ValidationCheckId::new("validation:sha256:one"),
target: ValidationTarget::Revision { revision_id },
check_name: "cargo test".to_owned(),
command: None,
status: ValidationStatus::Passed,
exit_code: Some(0),
trigger: ValidationTrigger::Manual,
source_fingerprint: None,
summary: None,
summary_content_type: Default::default(),
summary_artifact_path: Some(path.to_owned()),
summary_byte_size: Some(10),
summary_content_hash: Some("sha256:summary".to_owned()),
started_at: None,
completed_at: None,
log_artifact_content_hashes: Vec::new(),
},
"2026-05-13T10:00:00Z",
)
.unwrap()
}
}