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            let recorded = self
172                .store
173                .create_artifact(NewArtifact {
174                    id,
175                    run_id: upload.run_id,
176                    step_id: upload.step_id,
177                    name: upload.name,
178                    storage_key: key.clone(),
179                    content_type: upload.content_type,
180                    size_bytes: digest.size_bytes,
181                    sha256: digest.sha256,
182                })
183                .await;
184
185            match recorded {
186                Ok(artifact) => Ok(artifact),
187                Err(err) => {
188                    // The blob is unreachable without its record; drop it rather
189                    // than leave a byte-for-byte orphan behind a known failure.
190                    if let Err(cleanup) = self.blob.delete(&key).await {
191                        warn!(
192                            storage_key = %key,
193                            error = %cleanup,
194                            "failed to remove the blob of an unrecorded artifact"
195                        );
196                    }
197                    Err(err.into())
198                }
199            }
200        })
201    }
202
203    fn get<'a>(&'a self, artifact: &'a Artifact) -> ArtifactFuture<'a, ByteStream> {
204        Box::pin(async move { Ok(self.blob.get(&artifact.storage_key).await?) })
205    }
206}
207
208/// Where a step sits in its run, for resolving declared inputs.
209#[derive(Debug, Clone, Copy)]
210pub(crate) struct StepLocation {
211    /// Run being executed.
212    pub(crate) run_id: Uuid,
213    /// Attempt being executed.
214    pub(crate) attempt: u32,
215    /// Position of the step within the attempt.
216    pub(crate) position: u32,
217}
218
219/// Write every artifact a step declared as an input into its working directory.
220///
221/// Runs before the command starts, so the command sees the files it expects.
222pub(crate) async fn materialize_inputs(
223    sink: &Arc<dyn ArtifactSink>,
224    store: &Arc<dyn Store>,
225    config: &ShellConfig,
226    location: StepLocation,
227) -> Result<(), EngineError> {
228    let work_dir = working_dir(config);
229
230    for input in &config.inputs {
231        let artifact = store
232            .find_artifact_for_input(ArtifactLookup {
233                run_id: location.run_id,
234                attempt: location.attempt,
235                before_position: location.position,
236                step_name: input.step.clone(),
237                name: input.name.clone(),
238            })
239            .await?
240            .ok_or_else(|| EngineError::ArtifactNotFound {
241                step: input.step.clone(),
242                name: input.name.clone(),
243            })?;
244
245        let destination = work_dir.join(input.destination());
246        if let Some(parent) = destination.parent() {
247            create_dir_all(parent).await.map_err(ArtifactError::from)?;
248        }
249
250        let mut content = sink.get(&artifact).await?;
251        let mut file = File::create(&destination)
252            .await
253            .map_err(ArtifactError::from)?;
254        while let Some(chunk) = content.next().await {
255            file.write_all(&chunk?).await.map_err(ArtifactError::from)?;
256        }
257        file.flush().await.map_err(ArtifactError::from)?;
258
259        info!(
260            run_id = %location.run_id,
261            artifact = %input.name,
262            produced_by = %input.step,
263            destination = %destination.display(),
264            "artifact input materialized"
265        );
266    }
267
268    Ok(())
269}
270
271/// Store every file a step declared as an output.
272///
273/// `step_succeeded` decides how strict the collection is. On success, a pattern
274/// that matches nothing fails the step: a declared output that never appeared
275/// is a broken contract. On failure the collection is best-effort, because the
276/// partial files a failing step leaves behind are usually the useful ones.
277pub(crate) async fn collect_outputs(
278    sink: &Arc<dyn ArtifactSink>,
279    config: &ShellConfig,
280    run_id: Uuid,
281    step_id: Uuid,
282    step_name: &str,
283    step_succeeded: bool,
284) -> Result<(), EngineError> {
285    let work_dir = working_dir(config);
286
287    for output in &config.outputs {
288        let pattern = work_dir.join(&output.pattern);
289        let pattern = pattern.to_str().ok_or_else(|| {
290            EngineError::StepConfig(format!(
291                "output pattern {:?} is not valid UTF-8",
292                output.pattern
293            ))
294        })?;
295
296        let matches = glob(pattern)
297            .map_err(|err| {
298                EngineError::StepConfig(format!(
299                    "invalid output pattern {:?}: {err}",
300                    output.pattern
301                ))
302            })?
303            .filter_map(Result::ok)
304            .filter(|path| path.is_file())
305            .collect::<Vec<_>>();
306
307        if matches.is_empty() {
308            if step_succeeded {
309                return Err(EngineError::MissingArtifact {
310                    step: step_name.to_string(),
311                    pattern: output.pattern.clone(),
312                });
313            }
314            warn!(
315                run_id = %run_id,
316                step = %step_name,
317                pattern = %output.pattern,
318                "declared output matched no file on a failed step"
319            );
320            continue;
321        }
322
323        for path in matches {
324            let name = path
325                .file_name()
326                .and_then(|name| name.to_str())
327                .ok_or_else(|| {
328                    EngineError::StepConfig(format!(
329                        "output file {:?} has no valid UTF-8 name",
330                        path.display()
331                    ))
332                })?
333                .to_string();
334
335            let content_type = output
336                .content_type
337                .clone()
338                .unwrap_or_else(|| guess_content_type(&name));
339
340            let content = stream_from_path(&path).await?;
341            let artifact = sink
342                .put(
343                    ArtifactUpload {
344                        run_id,
345                        step_id,
346                        name: name.clone(),
347                        content_type,
348                    },
349                    content,
350                )
351                .await?;
352
353            info!(
354                run_id = %run_id,
355                step = %step_name,
356                artifact = %artifact.name,
357                size_bytes = artifact.size_bytes,
358                "artifact output stored"
359            );
360        }
361    }
362
363    Ok(())
364}
365
366/// Directory a shell step runs in, and the base for its artifact paths.
367fn working_dir(config: &ShellConfig) -> PathBuf {
368    PathBuf::from(config.dir.as_deref().unwrap_or("."))
369}
370
371#[cfg(test)]
372mod tests {
373    use std::collections::HashMap;
374
375    use futures_util::TryStreamExt;
376    use ironflow_artifacts::local::LocalBlobStore;
377    use ironflow_artifacts::stream_from_bytes;
378    use ironflow_store::entities::{NewRun, NewStep, StepKind, TriggerKind};
379    use ironflow_store::memory::InMemoryStore;
380    use serde_json::json;
381    use tempfile::TempDir;
382
383    use super::*;
384
385    async fn sink_with_step() -> (TempDir, DirectArtifactSink, Uuid, Uuid) {
386        let dir = TempDir::new().expect("temp dir");
387        let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
388        let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
389
390        let run = store
391            .create_run(NewRun {
392                workflow_name: "artifacts".to_string(),
393                trigger: TriggerKind::Manual,
394                payload: json!({}),
395                max_retries: 0,
396                handler_version: None,
397                labels: HashMap::new(),
398                scheduled_at: None,
399                created_by: None,
400                idempotency_key: None,
401                max_cost_usd: None,
402            })
403            .await
404            .expect("create run")
405            .into_run();
406
407        let step = store
408            .create_step(NewStep {
409                run_id: run.id,
410                name: "build".to_string(),
411                kind: StepKind::Shell,
412                position: 0,
413                input: None,
414            })
415            .await
416            .expect("create step");
417
418        let sink = DirectArtifactSink::new(blob, store);
419        (dir, sink, run.id, step.id)
420    }
421
422    fn upload(run_id: Uuid, step_id: Uuid, name: &str) -> ArtifactUpload {
423        ArtifactUpload {
424            run_id,
425            step_id,
426            name: name.to_string(),
427            content_type: "text/plain".to_string(),
428        }
429    }
430
431    #[tokio::test]
432    async fn put_records_size_and_hash() {
433        let (_dir, sink, run_id, step_id) = sink_with_step().await;
434
435        let artifact = sink
436            .put(
437                upload(run_id, step_id, "report.txt"),
438                stream_from_bytes(b"abc".to_vec()),
439            )
440            .await
441            .expect("put");
442
443        assert_eq!(artifact.size_bytes, 3);
444        assert_eq!(
445            artifact.sha256,
446            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
447        );
448    }
449
450    #[tokio::test]
451    async fn put_then_get_roundtrips_the_bytes() {
452        let (_dir, sink, run_id, step_id) = sink_with_step().await;
453
454        let artifact = sink
455            .put(
456                upload(run_id, step_id, "report.txt"),
457                stream_from_bytes(b"hello".to_vec()),
458            )
459            .await
460            .expect("put");
461
462        let chunks: Vec<bytes::Bytes> = sink
463            .get(&artifact)
464            .await
465            .expect("get")
466            .try_collect()
467            .await
468            .expect("collect");
469
470        assert_eq!(chunks.concat(), b"hello");
471    }
472
473    #[tokio::test]
474    async fn the_storage_key_never_embeds_the_name() {
475        let (_dir, sink, run_id, step_id) = sink_with_step().await;
476
477        let artifact = sink
478            .put(
479                upload(run_id, step_id, "report.txt"),
480                stream_from_bytes(b"x".to_vec()),
481            )
482            .await
483            .expect("put");
484
485        assert!(!artifact.storage_key.contains("report"));
486        assert!(artifact.storage_key.ends_with(&artifact.id.to_string()));
487    }
488
489    #[tokio::test]
490    async fn an_invalid_name_is_rejected_before_anything_is_written() {
491        let (dir, sink, run_id, step_id) = sink_with_step().await;
492
493        let err = sink
494            .put(
495                upload(run_id, step_id, "../escape"),
496                stream_from_bytes(b"x".to_vec()),
497            )
498            .await
499            .expect_err("invalid name");
500
501        assert!(matches!(err, EngineError::Artifact(_)));
502        assert!(!dir.path().join("artifacts").exists());
503    }
504
505    #[tokio::test]
506    async fn a_duplicate_name_fails_and_leaves_no_orphan_blob() {
507        let (dir, sink, run_id, step_id) = sink_with_step().await;
508
509        sink.put(
510            upload(run_id, step_id, "report.txt"),
511            stream_from_bytes(b"first".to_vec()),
512        )
513        .await
514        .expect("first");
515
516        let err = sink
517            .put(
518                upload(run_id, step_id, "report.txt"),
519                stream_from_bytes(b"second".to_vec()),
520            )
521            .await
522            .expect_err("duplicate");
523
524        assert!(matches!(err, EngineError::Store(_)));
525
526        let stored: Vec<_> = std::fs::read_dir(
527            dir.path()
528                .join("artifacts")
529                .join(run_id.to_string())
530                .join(step_id.to_string()),
531        )
532        .expect("read dir")
533        .filter_map(Result::ok)
534        .collect();
535        assert_eq!(stored.len(), 1, "the rejected blob was not cleaned up");
536    }
537}