1use crate::state::{Durable, Kind, now_ms, ulid};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::BTreeMap;
13
14pub const MAX_INLINE_BYTES: usize = 4 * 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct Artifact {
19 pub id: String,
20 pub name: String,
21 pub mime: String,
22 pub size: u64,
23 pub sha256: String,
24 pub content: Value,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub created_by: Option<String>,
27 #[serde(default)]
28 pub sensitive: bool,
29 #[serde(default)]
30 pub created: u64,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub owner: Option<String>,
34}
35
36impl Artifact {
37 pub fn meta(&self) -> Value {
38 json!({"id": self.id, "name": self.name, "mime": self.mime, "size": self.size, "sha256": self.sha256, "created_by": self.created_by, "sensitive": self.sensitive, "created": self.created, "owner": self.owner})
39 }
40}
41
42pub struct NewArtifact<'a> {
44 pub name: &'a str,
45 pub mime: Option<&'a str>,
46 pub content: Value,
47 pub created_by: Option<&'a str>,
48 pub sensitive: bool,
49 pub owner: Option<&'a str>,
50}
51
52#[derive(Default)]
54pub struct Artifacts {
55 map: BTreeMap<String, Artifact>,
56}
57
58impl Artifacts {
59 pub fn new() -> Artifacts {
60 Artifacts::default()
61 }
62
63 pub fn restore(&mut self, envelopes: &[crate::store::Envelope]) -> usize {
65 let mut n = 0;
66 for env in envelopes {
67 if let Ok(a) = serde_json::from_value::<Artifact>(env.state.clone()) {
68 self.map.insert(a.id.clone(), a);
69 n += 1;
70 }
71 }
72 n
73 }
74
75 pub fn create(&mut self, d: &Durable, spec: NewArtifact<'_>) -> Result<Value, String> {
77 let NewArtifact {
78 name,
79 mime,
80 content,
81 created_by,
82 sensitive,
83 owner,
84 } = spec;
85 if name.trim().is_empty() {
86 return Err("artifact.create: name must be non-empty".into());
87 }
88 let serialized = match &content {
89 Value::String(s) => s.clone(),
90 other => other.to_string(),
91 };
92 if serialized.len() > MAX_INLINE_BYTES {
93 return Err(format!(
94 "artifact.create: content is {} bytes; the inline cap is {MAX_INLINE_BYTES}",
95 serialized.len()
96 ));
97 }
98 let mime = mime.map(str::to_string).unwrap_or_else(|| {
99 if content.is_string() {
100 "text/plain".into()
101 } else {
102 "application/json".into()
103 }
104 });
105 let a = Artifact {
106 id: ulid::new(),
107 name: name.trim().to_string(),
108 mime,
109 size: serialized.len() as u64,
110 sha256: crate::sha::sha256_hex(serialized.as_bytes()),
111 content,
112 created_by: created_by.map(str::to_string),
113 sensitive,
114 created: now_ms(),
115 owner: owner.map(str::to_string),
116 };
117 d.put(
118 Kind::Artifact,
119 &a.id,
120 serde_json::to_value(&a).unwrap_or(Value::Null),
121 Some(a.sha256.clone()),
122 )
123 .map_err(|e| e.to_string())?;
124 let meta = a.meta();
125 self.map.insert(a.id.clone(), a);
126 Ok(meta)
127 }
128
129 pub fn get(&self, id: &str) -> Option<&Artifact> {
131 self.map.get(id)
132 }
133
134 pub fn get_value(&self, id: &str) -> Result<Value, String> {
138 let a = self
139 .map
140 .get(id)
141 .ok_or_else(|| format!("no such artifact {id:?}"))?;
142 let mut v = a.meta();
143 v["content"] = a.content.clone();
144 Ok(v)
145 }
146
147 pub fn delete(&mut self, d: &Durable, id: &str) -> Result<Value, String> {
149 if self.map.remove(id).is_none() {
150 return Err(format!("no such artifact {id:?}"));
151 }
152 d.delete(Kind::Artifact, id).map_err(|e| e.to_string())?;
153 Ok(json!({"ok": true, "id": id}))
154 }
155
156 pub fn list(&self, prefix: Option<&str>, limit: Option<usize>, owner: Option<&str>) -> Value {
158 let limit = limit.unwrap_or(100).max(1);
159 let mut items: Vec<Value> = self
160 .map
161 .values()
162 .filter(|a| prefix.is_none_or(|p| a.name.starts_with(p)))
163 .filter(|a| owner.is_none_or(|o| a.owner.as_deref() == Some(o)))
164 .map(Artifact::meta)
165 .collect();
166 let truncated = items.len() > limit;
167 items.truncate(limit);
168 json!({"artifacts": items, "truncated": truncated})
169 }
170
171 pub fn len(&self) -> usize {
172 self.map.len()
173 }
174 pub fn is_empty(&self) -> bool {
175 self.map.is_empty()
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::state::Policy;
183 use crate::store::memory::MemoryStore;
184 use std::sync::Arc;
185
186 #[test]
187 fn create_get_list_delete_and_restore() {
188 let d = Durable::new(
189 Arc::new(MemoryStore::new()),
190 "agentd",
191 "i",
192 Policy::default(),
193 None,
194 );
195 let mut a = Artifacts::new();
196 let m = a
197 .create(
198 &d,
199 NewArtifact {
200 name: "report.md",
201 mime: None,
202 content: json!("# hi"),
203 created_by: Some("root"),
204 sensitive: false,
205 owner: Some("task-1"),
206 },
207 )
208 .unwrap();
209 let id = m["id"].as_str().unwrap().to_string();
210 assert_eq!(m["mime"], json!("text/plain"));
211 assert_eq!(m["size"], json!(4));
212 let m2 = a
213 .create(
214 &d,
215 NewArtifact {
216 name: "data.json",
217 mime: None,
218 content: json!({"a": 1}),
219 created_by: None,
220 sensitive: true,
221 owner: None,
222 },
223 )
224 .unwrap();
225 assert_eq!(m2["mime"], json!("application/json"));
226 assert_eq!(a.get_value(&id).unwrap()["content"], json!("# hi"));
227 assert_eq!(
228 a.list(Some("rep"), None, None)["artifacts"]
229 .as_array()
230 .unwrap()
231 .len(),
232 1
233 );
234 assert_eq!(
235 a.list(None, None, Some("task-1"))["artifacts"]
236 .as_array()
237 .unwrap()
238 .len(),
239 1
240 );
241 assert_eq!(a.list(None, Some(1), None)["truncated"], json!(true));
242 assert!(
243 a.create(
244 &d,
245 NewArtifact {
246 name: "",
247 mime: None,
248 content: json!(1),
249 created_by: None,
250 sensitive: false,
251 owner: None,
252 },
253 )
254 .is_err()
255 );
256 let mut b = Artifacts::new();
258 assert_eq!(b.restore(d.restore().unwrap().of(Kind::Artifact)), 2);
259 assert!(b.get(&id).is_some());
260 b.delete(&d, &id).unwrap();
261 assert!(b.delete(&d, &id).is_err());
262 assert_eq!(d.restore().unwrap().of(Kind::Artifact).len(), 1);
263 }
264}