Skip to main content

agent_first_http/shared/
artifacts.rs

1//! Artifact tokens (`architecture.md §8`) + on-disk path resolution.
2//!
3//! Default fetches start with the HTTP-safe body artifact and expand to the
4//! browser artifact set only when rendering is used. `Storage` stays opt-in due
5//! to sensitive-data risk. Each artifact maps to a fixed filename; the response
6//! JSON references produced files as absolute paths under `--out/<request_id>/`.
7
8use serde::{Deserialize, Serialize};
9
10use crate::shared::ids::RequestId;
11
12/// Artifact kinds. `Body` is the default HTTP-safe artifact; browser-backed
13/// fetches can produce the richer DOM/network artifacts. `Storage` is
14/// default-off due to sensitive-data risk — agents must request it explicitly
15/// via `--want`.
16/// Per-artifact warnings (§8 last ¶) are emitted when a backend lacks
17/// the capability rather than failing the fetch.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Artifact {
21    Body,
22    RenderedHtml,
23    Text,
24    Content,
25    ContentJson,
26    Screenshot,
27    Network,
28    Console,
29    Observation,
30    /// localStorage + sessionStorage + IndexedDB names. Default off.
31    Storage,
32}
33
34impl Artifact {
35    /// The artifact set used before browser escalation is known.
36    pub const HTTP_DEFAULT: [Self; 1] = [Self::Body];
37
38    /// The default artifacts captured once a browser render is actually used.
39    /// `Storage` is intentionally excluded — sensitive data risk means agents
40    /// must opt in with `--want storage`.
41    pub const BROWSER_DEFAULT: [Self; 9] = [
42        Self::Body,
43        Self::RenderedHtml,
44        Self::Text,
45        Self::Content,
46        Self::ContentJson,
47        Self::Screenshot,
48        Self::Network,
49        Self::Console,
50        Self::Observation,
51    ];
52
53    /// All non-sensitive artifacts. Kept as a stable SDK convenience alias.
54    pub const ALL: [Self; 9] = Self::BROWSER_DEFAULT;
55
56    /// Default filename portion (extension chosen from content-type for
57    /// `Body`; fixed for everything else). `body.<ext>` is filled in at
58    /// write time when the response headers are known.
59    #[must_use]
60    pub const fn filename_template(self) -> &'static str {
61        match self {
62            Self::Body => "body",
63            Self::RenderedHtml => "rendered.html",
64            Self::Text => "text.txt",
65            Self::Content => "content.md",
66            Self::ContentJson => "content.json",
67            Self::Screenshot => "page.png",
68            Self::Network => "network.json",
69            Self::Console => "console.json",
70            Self::Observation => "observation.json",
71            Self::Storage => "storage.json",
72        }
73    }
74
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Body => "body",
79            Self::RenderedHtml => "rendered_html",
80            Self::Text => "text",
81            Self::Content => "content",
82            Self::ContentJson => "content_json",
83            Self::Screenshot => "screenshot",
84            Self::Network => "network",
85            Self::Console => "console",
86            Self::Observation => "observation",
87            Self::Storage => "storage",
88        }
89    }
90}
91
92impl std::fmt::Display for Artifact {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(self.as_str())
95    }
96}
97
98/// Concrete on-disk locations for a single fetch. Built from `--out` (or
99/// the default system temp `afhttp-out/`) plus the request id.
100#[derive(Debug, Clone)]
101pub struct ArtifactPaths {
102    pub root: std::path::PathBuf,
103}
104
105impl ArtifactPaths {
106    /// Compose `<base>/<request_id>/`. Does not create the directory; the
107    /// fetch writer creates it just before producing the first file so a
108    /// fetch that fails before any artifact is captured leaves no
109    /// half-empty dirs behind.
110    #[must_use]
111    pub fn new(base: impl Into<std::path::PathBuf>, request_id: &RequestId) -> Self {
112        let base = crate::shared::path::absolute_lexical(base.into());
113        Self {
114            root: base.join(request_id.as_str()),
115        }
116    }
117
118    /// Path for a given artifact. `Body` returns `body` with no extension;
119    /// callers append the content-type-derived suffix.
120    #[must_use]
121    pub fn file_for(&self, artifact: Artifact) -> std::path::PathBuf {
122        self.root.join(artifact.filename_template())
123    }
124
125    /// `<root>/network-bodies/<request_id>.<ext>` per `§8`. The CDP
126    /// `request_id` (not the fetch request id) is what's interpolated by
127    /// callers; this helper just hands back the directory.
128    #[must_use]
129    pub fn network_bodies_dir(&self) -> std::path::PathBuf {
130        self.root.join("network-bodies")
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn default_tokens_present() {
140        assert_eq!(Artifact::HTTP_DEFAULT, [Artifact::Body]);
141        assert_eq!(Artifact::ALL.len(), 9);
142        assert!(Artifact::ALL.contains(&Artifact::Content));
143        assert!(Artifact::ALL.contains(&Artifact::ContentJson));
144        // Storage is intentionally absent from ALL (default-off).
145        assert!(!Artifact::ALL.contains(&Artifact::Storage));
146    }
147
148    #[test]
149    fn filename_templates_match_spec_table() {
150        let table = [
151            (Artifact::Body, "body"),
152            (Artifact::RenderedHtml, "rendered.html"),
153            (Artifact::Text, "text.txt"),
154            (Artifact::Content, "content.md"),
155            (Artifact::ContentJson, "content.json"),
156            (Artifact::Screenshot, "page.png"),
157            (Artifact::Network, "network.json"),
158            (Artifact::Console, "console.json"),
159            (Artifact::Observation, "observation.json"),
160            (Artifact::Storage, "storage.json"),
161        ];
162        for (a, f) in table {
163            assert_eq!(a.filename_template(), f);
164        }
165    }
166
167    #[test]
168    fn artifact_paths_compose_under_request_id() {
169        let rid = RequestId("abc-123".into());
170        let paths = ArtifactPaths::new("/tmp/out", &rid);
171        assert_eq!(
172            paths.file_for(Artifact::Observation),
173            std::path::PathBuf::from("/tmp/out/abc-123/observation.json"),
174        );
175        assert_eq!(
176            paths.network_bodies_dir(),
177            std::path::PathBuf::from("/tmp/out/abc-123/network-bodies"),
178        );
179    }
180
181    #[test]
182    fn artifact_paths_absolutize_relative_out_dir() {
183        let rid = RequestId("abc-123".into());
184        let paths = ArtifactPaths::new("relative-out", &rid);
185        assert!(
186            paths.root.is_absolute(),
187            "artifact root must be absolute: {}",
188            paths.root.display()
189        );
190    }
191
192    #[test]
193    fn artifacts_serialize_to_snake_case() {
194        let all_including_storage: &[Artifact] = &[
195            Artifact::Body,
196            Artifact::RenderedHtml,
197            Artifact::Text,
198            Artifact::Content,
199            Artifact::ContentJson,
200            Artifact::Screenshot,
201            Artifact::Network,
202            Artifact::Console,
203            Artifact::Observation,
204            Artifact::Storage,
205        ];
206        for a in all_including_storage {
207            let s = serde_json::to_string(a).unwrap_or_default();
208            assert_eq!(s, format!("\"{}\"", a.as_str()));
209        }
210    }
211}