Skip to main content

ironflow_engine/
artifact.rs

1//! Artifact production and consumption from a running workflow.
2//!
3//! [`ArtifactSink`] is the seam between the engine and wherever artifact bytes
4//! actually live. The engine never talks to a blob store directly, because the
5//! two execution topologies reach storage differently:
6//!
7//! - in-process (API server, tests): [`DirectArtifactSink`] writes to the blob
8//!   store and records the metadata itself;
9//! - remote worker: the worker's sink uploads over the internal HTTP API, which
10//!   keeps storage credentials on the API side only.
11//!
12//! Ordering is always "bytes first, metadata second". The metadata row is the
13//! source of truth, so a crash in between leaves an unreferenced blob rather
14//! than a record pointing at nothing.
15
16use std::future::Future;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use futures_util::StreamExt;
22use glob::glob;
23use tokio::fs::{File, create_dir_all};
24use tokio::io::AsyncWriteExt;
25use tracing::{info, warn};
26use uuid::Uuid;
27
28use ironflow_artifacts::blob_store::{BlobStore, ByteStream};
29use ironflow_artifacts::error::ArtifactError;
30use ironflow_artifacts::name::{guess_content_type, storage_key, validate_artifact_name};
31use ironflow_artifacts::stream_from_path;
32use ironflow_store::entities::{Artifact, ArtifactLookup, NewArtifact};
33use ironflow_store::store::Store;
34
35use crate::config::ShellConfig;
36use crate::error::EngineError;
37
38/// Boxed future returned by [`ArtifactSink`] methods -- keeps the trait object safe.
39pub type ArtifactFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, EngineError>> + Send + 'a>>;
40
41/// Everything needed to record an artifact, minus the bytes.
42///
43/// # Examples
44///
45/// ```
46/// use ironflow_engine::artifact::ArtifactUpload;
47/// use uuid::Uuid;
48///
49/// let upload = ArtifactUpload {
50///     run_id: Uuid::now_v7(),
51///     step_id: Uuid::now_v7(),
52///     name: "report.html".to_string(),
53///     content_type: "text/html".to_string(),
54/// };
55/// assert_eq!(upload.name, "report.html");
56/// ```
57#[derive(Debug, Clone)]
58pub struct ArtifactUpload {
59    /// The run the artifact belongs to.
60    pub run_id: Uuid,
61    /// The step that produced it.
62    pub step_id: Uuid,
63    /// User-facing file name, unique within the step.
64    pub name: String,
65    /// MIME type to serve on download.
66    pub content_type: String,
67}
68
69/// Where a running workflow reads and writes artifact bytes.
70///
71/// # Examples
72///
73/// ```no_run
74/// use std::sync::Arc;
75///
76/// use ironflow_artifacts::stream_from_bytes;
77/// use ironflow_engine::artifact::{ArtifactSink, ArtifactUpload};
78/// use uuid::Uuid;
79///
80/// # async fn example(sink: Arc<dyn ArtifactSink>, run_id: Uuid, step_id: Uuid)
81/// # -> Result<(), ironflow_engine::error::EngineError> {
82/// let artifact = sink
83///     .put(
84///         ArtifactUpload {
85///             run_id,
86///             step_id,
87///             name: "report.json".to_string(),
88///             content_type: "application/json".to_string(),
89///         },
90///         stream_from_bytes(b"{}".to_vec()),
91///     )
92///     .await?;
93///
94/// assert_eq!(artifact.size_bytes, 2);
95/// # Ok(())
96/// # }
97/// ```
98pub trait ArtifactSink: Send + Sync {
99    /// Store the bytes of an artifact and record its metadata.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`EngineError::Artifact`] when the name is invalid or storage
104    /// fails, and [`EngineError::Store`] when the metadata cannot be recorded
105    /// (for instance when the step already owns that name).
106    fn put<'a>(
107        &'a self,
108        upload: ArtifactUpload,
109        content: ByteStream,
110    ) -> ArtifactFuture<'a, Artifact>;
111
112    /// Open the bytes of a recorded artifact for reading.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`EngineError::Artifact`] when the blob is missing or storage fails.
117    fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream>;
118}
119
120/// [`ArtifactSink`] backed by a blob store and a run store in the same process.
121///
122/// Used by the API server and by any in-process engine. A remote worker uses an
123/// HTTP-backed sink instead.
124///
125/// # Examples
126///
127/// ```no_run
128/// use std::sync::Arc;
129///
130/// use ironflow_artifacts::local::LocalBlobStore;
131/// use ironflow_engine::artifact::DirectArtifactSink;
132/// use ironflow_store::memory::InMemoryStore;
133/// use ironflow_store::store::Store;
134///
135/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
136/// let blob = Arc::new(LocalBlobStore::new("/var/lib/ironflow/artifacts"));
137/// let sink = DirectArtifactSink::new(blob, store);
138/// # drop(sink);
139/// ```
140pub struct DirectArtifactSink {
141    blob: Arc<dyn BlobStore>,
142    store: Arc<dyn Store>,
143}
144
145impl DirectArtifactSink {
146    /// Build a sink over a blob store and the run store holding the metadata.
147    pub fn new(blob: Arc<dyn BlobStore>, store: Arc<dyn Store>) -> Self {
148        Self { blob, store }
149    }
150}
151
152impl std::fmt::Debug for DirectArtifactSink {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_struct("DirectArtifactSink").finish_non_exhaustive()
155    }
156}
157
158impl ArtifactSink for DirectArtifactSink {
159    fn put<'a>(
160        &'a self,
161        upload: ArtifactUpload,
162        content: ByteStream,
163    ) -> ArtifactFuture<'a, Artifact> {
164        Box::pin(async move {
165            validate_artifact_name(&upload.name)?;
166
167            let id = Uuid::now_v7();
168            let key = storage_key(upload.run_id, upload.step_id, id);
169            let digest = self.blob.put(&key, content).await?;
170
171            // Dedup: reuse an existing blob if one with the same SHA-256 exists.
172            // Dedup: reuse an existing blob if one with the same SHA-256 exists.
173            let (final_key, dedup_hit) =
174                match self.store.find_artifact_by_sha256(&digest.sha256).await {
175                    Ok(Some(existing)) => {
176                        if let Err(cleanup) = self.blob.delete(&key).await {
177                            warn!(
178                                storage_key = %key,
179                                error = %cleanup,
180                                "failed to remove deduplicated blob"
181                            );
182                        }
183                        info!(
184                            sha256 = %digest.sha256,
185                            reused_key = %existing.storage_key,
186                            "artifact deduplicated"
187                        );
188                        (existing.storage_key, true)
189                    }
190                    _ => (key.clone(), false),
191                };
192
193            let recorded = self
194                .store
195                .create_artifact(NewArtifact {
196                    id,
197                    run_id: upload.run_id,
198                    step_id: upload.step_id,
199                    name: upload.name,
200                    storage_key: final_key.clone(),
201                    content_type: upload.content_type,
202                    size_bytes: digest.size_bytes,
203                    sha256: digest.sha256,
204                })
205                .await;
206
207            match recorded {
208                Ok(artifact) => Ok(artifact),
209                Err(err) => {
210                    if !dedup_hit && let Err(cleanup) = self.blob.delete(&key).await {
211                        warn!(
212                            storage_key = %key,
213                            error = %cleanup,
214                            "failed to remove the blob of an unrecorded artifact"
215                        );
216                    }
217                    Err(err.into())
218                }
219            }
220        })
221    }
222
223    fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream> {
224        Box::pin(async move { Ok(self.blob.get(&artifact.storage_key).await?) })
225    }
226}
227
228/// Where a step sits in its run, for resolving declared inputs.
229#[derive(Debug, Clone, Copy)]
230pub(crate) struct StepLocation {
231    /// Run being executed.
232    pub(crate) run_id: Uuid,
233    /// Attempt being executed.
234    pub(crate) attempt: u32,
235    /// Position of the step within the attempt.
236    pub(crate) position: u32,
237}
238
239/// Write every artifact a step declared as an input into its working directory.
240///
241/// Runs before the command starts, so the command sees the files it expects.
242pub(crate) async fn materialize_inputs(
243    sink: &Arc<dyn ArtifactSink>,
244    store: &Arc<dyn Store>,
245    config: &ShellConfig,
246    location: StepLocation,
247) -> Result<(), EngineError> {
248    let work_dir = working_dir(config);
249
250    for input in &config.inputs {
251        let artifact = store
252            .find_artifact_for_input(ArtifactLookup {
253                run_id: location.run_id,
254                attempt: location.attempt,
255                before_position: location.position,
256                step_name: input.step.clone(),
257                name: input.name.clone(),
258            })
259            .await?
260            .ok_or_else(|| EngineError::ArtifactNotFound {
261                step: input.step.clone(),
262                name: input.name.clone(),
263            })?;
264
265        let destination = work_dir.join(input.destination());
266        if let Some(parent) = destination.parent() {
267            create_dir_all(parent).await.map_err(ArtifactError::from)?;
268        }
269
270        let mut content = sink.get(&artifact).await?;
271        let mut file = File::create(&destination)
272            .await
273            .map_err(ArtifactError::from)?;
274        while let Some(chunk) = content.next().await {
275            file.write_all(&chunk?).await.map_err(ArtifactError::from)?;
276        }
277        file.flush().await.map_err(ArtifactError::from)?;
278
279        info!(
280            run_id = %location.run_id,
281            artifact = %input.name,
282            produced_by = %input.step,
283            destination = %destination.display(),
284            "artifact input materialized"
285        );
286    }
287
288    Ok(())
289}
290
291/// Store every file a step declared as an output.
292///
293/// `step_succeeded` decides how strict the collection is. On success, a pattern
294/// that matches nothing fails the step: a declared output that never appeared
295/// is a broken contract. On failure the collection is best-effort, because the
296/// partial files a failing step leaves behind are usually the useful ones.
297pub(crate) async fn collect_outputs(
298    sink: &Arc<dyn ArtifactSink>,
299    config: &ShellConfig,
300    run_id: Uuid,
301    step_id: Uuid,
302    step_name: &str,
303    step_succeeded: bool,
304) -> Result<(), EngineError> {
305    let work_dir = working_dir(config);
306
307    for output in &config.outputs {
308        let pattern = work_dir.join(&output.pattern);
309        let pattern = pattern.to_str().ok_or_else(|| {
310            EngineError::StepConfig(format!(
311                "output pattern {:?} is not valid UTF-8",
312                output.pattern
313            ))
314        })?;
315
316        let matches = glob(pattern)
317            .map_err(|err| {
318                EngineError::StepConfig(format!(
319                    "invalid output pattern {:?}: {err}",
320                    output.pattern
321                ))
322            })?
323            .filter_map(Result::ok)
324            .filter(|path| path.is_file())
325            .collect::<Vec<_>>();
326
327        if matches.is_empty() {
328            if step_succeeded {
329                return Err(EngineError::MissingArtifact {
330                    step: step_name.to_string(),
331                    pattern: output.pattern.clone(),
332                });
333            }
334            warn!(
335                run_id = %run_id,
336                step = %step_name,
337                pattern = %output.pattern,
338                "declared output matched no file on a failed step"
339            );
340            continue;
341        }
342
343        for path in matches {
344            let name = path
345                .file_name()
346                .and_then(|name| name.to_str())
347                .ok_or_else(|| {
348                    EngineError::StepConfig(format!(
349                        "output file {:?} has no valid UTF-8 name",
350                        path.display()
351                    ))
352                })?
353                .to_string();
354
355            let content_type = output
356                .content_type
357                .clone()
358                .unwrap_or_else(|| guess_content_type(&name));
359
360            let content = stream_from_path(&path).await?;
361            let artifact = sink
362                .put(
363                    ArtifactUpload {
364                        run_id,
365                        step_id,
366                        name: name.clone(),
367                        content_type,
368                    },
369                    content,
370                )
371                .await?;
372
373            info!(
374                run_id = %run_id,
375                step = %step_name,
376                artifact = %artifact.name,
377                size_bytes = artifact.size_bytes,
378                "artifact output stored"
379            );
380        }
381    }
382
383    Ok(())
384}
385
386/// Directory a shell step runs in, and the base for its artifact paths.
387fn working_dir(config: &ShellConfig) -> PathBuf {
388    PathBuf::from(config.dir.as_deref().unwrap_or("."))
389}
390
391#[cfg(test)]
392mod tests {
393    use std::collections::HashMap;
394
395    use futures_util::TryStreamExt;
396    use ironflow_artifacts::local::LocalBlobStore;
397    use ironflow_artifacts::stream_from_bytes;
398    use ironflow_store::entities::{NewRun, NewStep, StepKind, TriggerKind, step_trace_id};
399    use ironflow_store::memory::InMemoryStore;
400    use serde_json::json;
401    use tempfile::TempDir;
402
403    use super::*;
404
405    fn count_files_recursive(dir: &std::path::Path) -> usize {
406        let mut count = 0;
407        if let Ok(entries) = std::fs::read_dir(dir) {
408            for entry in entries.flatten() {
409                let path = entry.path();
410                if path.is_dir() {
411                    count += count_files_recursive(&path);
412                } else if path.is_file() {
413                    count += 1;
414                }
415            }
416        }
417        count
418    }
419
420    async fn sink_with_step() -> (TempDir, DirectArtifactSink, Uuid, Uuid) {
421        let dir = TempDir::new().expect("temp dir");
422        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
423        let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
424
425        let run = store
426            .create_run(NewRun {
427                workflow_name: "artifacts".to_string(),
428                trigger: TriggerKind::Manual,
429                payload: json!({}),
430                max_retries: 0,
431                handler_version: None,
432                labels: HashMap::new(),
433                scheduled_at: None,
434                created_by: None,
435                idempotency_key: None,
436                max_cost_usd: None,
437            })
438            .await
439            .expect("create run")
440            .into_run();
441
442        let step = store
443            .create_step(NewStep {
444                run_id: run.id,
445                trace_id: step_trace_id(run.id, "build", 0),
446                name: "build".to_string(),
447                kind: StepKind::Shell,
448                position: 0,
449                input: None,
450                is_error_handler: false,
451            })
452            .await
453            .expect("create step");
454
455        let sink = DirectArtifactSink::new(blob, store);
456        (dir, sink, run.id, step.id)
457    }
458
459    fn upload(run_id: Uuid, step_id: Uuid, name: &str) -> ArtifactUpload {
460        ArtifactUpload {
461            run_id,
462            step_id,
463            name: name.to_string(),
464            content_type: "text/plain".to_string(),
465        }
466    }
467
468    #[tokio::test]
469    async fn put_records_size_and_hash() {
470        let (_dir, sink, run_id, step_id) = sink_with_step().await;
471
472        let artifact = sink
473            .put(
474                upload(run_id, step_id, "report.txt"),
475                stream_from_bytes(b"abc".to_vec()),
476            )
477            .await
478            .expect("put");
479
480        assert_eq!(artifact.size_bytes, 3);
481        assert_eq!(
482            artifact.sha256,
483            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
484        );
485    }
486
487    #[tokio::test]
488    async fn put_then_get_roundtrips_the_bytes() {
489        let (_dir, sink, run_id, step_id) = sink_with_step().await;
490
491        let artifact = sink
492            .put(
493                upload(run_id, step_id, "report.txt"),
494                stream_from_bytes(b"hello".to_vec()),
495            )
496            .await
497            .expect("put");
498
499        let chunks: Vec<bytes::Bytes> = sink
500            .get(&artifact)
501            .await
502            .expect("get")
503            .try_collect()
504            .await
505            .expect("collect");
506
507        assert_eq!(chunks.concat(), b"hello");
508    }
509
510    #[tokio::test]
511    async fn the_storage_key_never_embeds_the_name() {
512        let (_dir, sink, run_id, step_id) = sink_with_step().await;
513
514        let artifact = sink
515            .put(
516                upload(run_id, step_id, "report.txt"),
517                stream_from_bytes(b"x".to_vec()),
518            )
519            .await
520            .expect("put");
521
522        assert!(!artifact.storage_key.contains("report"));
523        assert!(artifact.storage_key.ends_with(&artifact.id.to_string()));
524    }
525
526    #[tokio::test]
527    async fn an_invalid_name_is_rejected_before_anything_is_written() {
528        let (dir, sink, run_id, step_id) = sink_with_step().await;
529
530        let err = sink
531            .put(
532                upload(run_id, step_id, "../escape"),
533                stream_from_bytes(b"x".to_vec()),
534            )
535            .await
536            .expect_err("invalid name");
537
538        assert!(matches!(err, EngineError::Artifact(_)));
539        assert!(!dir.path().join("artifacts").exists());
540    }
541
542    #[tokio::test]
543    async fn dedup_same_sha256_reuses_storage_key() {
544        let (dir, sink, run_id, step_id) = sink_with_step().await;
545
546        let second_step_id = sink
547            .store
548            .create_step(NewStep {
549                run_id,
550                trace_id: step_trace_id(run_id, "test", 1),
551                name: "test".to_string(),
552                kind: StepKind::Shell,
553                position: 1,
554                input: None,
555                is_error_handler: false,
556            })
557            .await
558            .expect("create step")
559            .id;
560
561        let first = sink
562            .put(
563                upload(run_id, step_id, "report.txt"),
564                stream_from_bytes(b"identical content".to_vec()),
565            )
566            .await
567            .expect("first put");
568
569        let second = sink
570            .put(
571                upload(run_id, second_step_id, "report-copy.txt"),
572                stream_from_bytes(b"identical content".to_vec()),
573            )
574            .await
575            .expect("second put");
576
577        assert_eq!(first.sha256, second.sha256);
578        assert_eq!(
579            first.storage_key, second.storage_key,
580            "dedup should reuse the same storage_key"
581        );
582
583        // Only one blob file should exist on disk: the dedup blob was deleted
584        let file_count = count_files_recursive(dir.path());
585        assert_eq!(file_count, 1, "dedup should not store a second copy");
586    }
587
588    #[tokio::test]
589    async fn dedup_different_sha256_gets_separate_storage_key() {
590        let (_dir, sink, run_id, step_id) = sink_with_step().await;
591
592        let second_step_id = sink
593            .store
594            .create_step(NewStep {
595                run_id,
596                trace_id: step_trace_id(run_id, "test", 1),
597                name: "test".to_string(),
598                kind: StepKind::Shell,
599                position: 1,
600                input: None,
601                is_error_handler: false,
602            })
603            .await
604            .expect("create step")
605            .id;
606
607        let first = sink
608            .put(
609                upload(run_id, step_id, "a.txt"),
610                stream_from_bytes(b"content A".to_vec()),
611            )
612            .await
613            .expect("first put");
614
615        let second = sink
616            .put(
617                upload(run_id, second_step_id, "b.txt"),
618                stream_from_bytes(b"content B".to_vec()),
619            )
620            .await
621            .expect("second put");
622
623        assert_ne!(first.sha256, second.sha256);
624        assert_ne!(
625            first.storage_key, second.storage_key,
626            "different content should get different storage keys"
627        );
628    }
629
630    #[tokio::test]
631    async fn a_duplicate_name_fails_and_leaves_no_orphan_blob() {
632        let (dir, sink, run_id, step_id) = sink_with_step().await;
633
634        sink.put(
635            upload(run_id, step_id, "report.txt"),
636            stream_from_bytes(b"first".to_vec()),
637        )
638        .await
639        .expect("first");
640
641        let err = sink
642            .put(
643                upload(run_id, step_id, "report.txt"),
644                stream_from_bytes(b"second".to_vec()),
645            )
646            .await
647            .expect_err("duplicate");
648
649        assert!(matches!(err, EngineError::Store(_)));
650
651        let stored: Vec<_> = std::fs::read_dir(
652            dir.path()
653                .join("artifacts")
654                .join(run_id.to_string())
655                .join(step_id.to_string()),
656        )
657        .expect("read dir")
658        .filter_map(Result::ok)
659        .collect();
660        assert_eq!(stored.len(), 1, "the rejected blob was not cleaned up");
661    }
662}