1use rusqlite::params;
26use sha2::{Digest, Sha256};
27
28use super::error::StoreError;
29use super::store::Store;
30
31#[derive(Debug, Clone, PartialEq)]
35pub struct BundleVersion {
36 pub id: String,
37 pub bundle_id: String,
38 pub name: String,
39 pub instructions: String,
40 pub content_hash: String,
41 pub parent_version_id: Option<String>,
42 pub source: String,
43 pub source_detail: String,
44 pub written_by: String,
54 pub written_by_uid: String,
60 pub created_at: i64,
61}
62
63#[derive(Debug, Clone, PartialEq)]
66pub struct BundleVersionSummary {
67 pub id: String,
68 pub content_hash: String,
69 pub parent_version_id: Option<String>,
70 pub source: String,
71 pub source_detail: String,
72 pub written_by: String,
73 pub written_by_uid: String,
75 pub created_at: i64,
76}
77
78use agentmux_common::time::now_ms;
79
80pub(crate) fn content_hash(name: &str, instructions: &str) -> String {
86 let mut hasher = Sha256::new();
87 hasher.update(name.as_bytes());
88 hasher.update(b"\0");
89 hasher.update(instructions.as_bytes());
90 hex::encode(hasher.finalize())
91}
92
93#[allow(clippy::too_many_arguments)]
103pub(crate) fn bundle_version_insert_tx(
104 tx: &rusqlite::Transaction,
105 bundle_id: &str,
106 name: &str,
107 instructions: &str,
108 source: &str,
109 source_detail: &str,
110 written_by: &str,
111 written_by_uid: &str,
112) -> Result<BundleVersion, StoreError> {
113 let id = uuid::Uuid::new_v4().to_string();
114 let hash = content_hash(name, instructions);
115 let created_at = now_ms();
116 let parent_version_id: Option<String> = {
117 let mut stmt = tx.prepare(
118 "SELECT id FROM db_bundle_versions
119 WHERE bundle_id = ?1
120 ORDER BY created_at DESC, rowid DESC
121 LIMIT 1",
122 )?;
123 match stmt.query_row(params![bundle_id], |row| row.get::<_, String>(0)) {
124 Ok(id) => Some(id),
125 Err(rusqlite::Error::QueryReturnedNoRows) => None,
126 Err(e) => return Err(e.into()),
127 }
128 };
129 tx.execute(
130 "INSERT INTO db_bundle_versions
131 (id, bundle_id, name, instructions, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid)
132 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
133 params![
134 id,
135 bundle_id,
136 name,
137 instructions,
138 hash,
139 parent_version_id,
140 source,
141 source_detail,
142 written_by,
143 created_at,
144 written_by_uid,
145 ],
146 )?;
147
148 Ok(BundleVersion {
149 id,
150 bundle_id: bundle_id.to_string(),
151 name: name.to_string(),
152 instructions: instructions.to_string(),
153 content_hash: hash,
154 parent_version_id,
155 source: source.to_string(),
156 source_detail: source_detail.to_string(),
157 written_by: written_by.to_string(),
158 written_by_uid: written_by_uid.to_string(),
159 created_at,
160 })
161}
162
163impl Store {
164 #[cfg(test)]
178 pub fn bundle_version_insert(
179 &self,
180 bundle_id: &str,
181 name: &str,
182 instructions: &str,
183 source: &str,
184 source_detail: &str,
185 written_by: &str,
186 ) -> Result<BundleVersion, StoreError> {
187 let mut conn = self.conn.lock().unwrap();
188 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
189 let version = bundle_version_insert_tx(&tx, bundle_id, name, instructions, source, source_detail, written_by, "")?;
191 tx.commit()?;
192 Ok(version)
193 }
194
195 pub fn bundle_version_list(&self, bundle_id: &str) -> Result<Vec<BundleVersionSummary>, StoreError> {
207 let conn = self.conn.lock().unwrap();
208 let mut stmt = conn.prepare(
209 "SELECT id, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid
210 FROM db_bundle_versions
211 WHERE bundle_id = ?1
212 ORDER BY created_at DESC, rowid DESC",
213 )?;
214 let rows = stmt
215 .query_map(params![bundle_id], |row| {
216 Ok(BundleVersionSummary {
217 id: row.get(0)?,
218 content_hash: row.get(1)?,
219 parent_version_id: row.get(2)?,
220 source: row.get(3)?,
221 source_detail: row.get(4)?,
222 written_by: row.get(5)?,
223 created_at: row.get(6)?,
224 written_by_uid: row.get(7)?,
225 })
226 })?
227 .collect::<Result<Vec<_>, _>>()?;
228 Ok(rows)
229 }
230
231 pub fn bundle_version_get(&self, version_id: &str) -> Result<Option<BundleVersion>, StoreError> {
237 let conn = self.conn.lock().unwrap();
238 let mut stmt = conn.prepare(
239 "SELECT id, bundle_id, name, instructions, content_hash, parent_version_id, source, source_detail, written_by, created_at, written_by_uid
240 FROM db_bundle_versions
241 WHERE id = ?1",
242 )?;
243 match stmt.query_row(params![version_id], |row| {
244 Ok(BundleVersion {
245 id: row.get(0)?,
246 bundle_id: row.get(1)?,
247 name: row.get(2)?,
248 instructions: row.get(3)?,
249 content_hash: row.get(4)?,
250 parent_version_id: row.get(5)?,
251 source: row.get(6)?,
252 source_detail: row.get(7)?,
253 written_by: row.get(8)?,
254 created_at: row.get(9)?,
255 written_by_uid: row.get(10)?,
256 })
257 }) {
258 Ok(v) => Ok(Some(v)),
259 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
260 Err(e) => Err(e.into()),
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn test_store() -> Store {
270 Store::open_shared(std::path::Path::new(":memory:")).unwrap()
271 }
272
273 #[test]
274 fn insert_chains_parent_version_id() {
275 let store = test_store();
276 let v1 = store
277 .bundle_version_insert("bundle-1", "Coding Standards", "use tabs", "agent_inferred", "{}", "agent-1")
278 .unwrap();
279 assert!(v1.parent_version_id.is_none());
280
281 let v2 = store
282 .bundle_version_insert("bundle-1", "Coding Standards", "use spaces", "agent_inferred", "{}", "agent-1")
283 .unwrap();
284 assert_eq!(v2.parent_version_id, Some(v1.id.clone()));
285 assert_ne!(v1.content_hash, v2.content_hash);
286 }
287
288 #[test]
289 fn list_is_newest_first_and_scoped_to_one_bundle() {
290 let store = test_store();
291 store
292 .bundle_version_insert("bundle-a", "A", "content 1", "agent_inferred", "{}", "agent-1")
293 .unwrap();
294 let a2 = store
295 .bundle_version_insert("bundle-a", "A", "content 2", "agent_inferred", "{}", "agent-1")
296 .unwrap();
297 store
298 .bundle_version_insert("bundle-b", "B", "unrelated", "agent_inferred", "{}", "agent-1")
299 .unwrap();
300
301 let history = store.bundle_version_list("bundle-a").unwrap();
302 assert_eq!(history.len(), 2);
303 assert_eq!(history[0].id, a2.id, "newest first");
304 }
305
306 #[test]
307 fn get_returns_full_content_by_id() {
308 let store = test_store();
309 let v = store
310 .bundle_version_insert("bundle-1", "Name", "the body", "human", "{}", "armory-ui")
311 .unwrap();
312 let fetched = store.bundle_version_get(&v.id).unwrap().expect("version exists");
313 assert_eq!(fetched.instructions, "the body");
314 assert_eq!(fetched.bundle_id, "bundle-1");
315 assert_eq!(fetched.written_by, "armory-ui");
316 }
317
318 #[test]
319 fn get_returns_none_for_unknown_id() {
320 let store = test_store();
321 assert!(store.bundle_version_get("does-not-exist").unwrap().is_none());
322 }
323
324 #[test]
325 fn renaming_without_changing_instructions_still_changes_the_hash() {
326 let store = test_store();
327 let v1 = store
328 .bundle_version_insert("bundle-1", "Old Name", "same body", "agent_inferred", "{}", "agent-1")
329 .unwrap();
330 let v2 = store
331 .bundle_version_insert("bundle-1", "New Name", "same body", "agent_inferred", "{}", "agent-1")
332 .unwrap();
333 assert_ne!(v1.content_hash, v2.content_hash, "renaming is a real edit, not a no-op");
334 }
335
336 #[test]
341 fn written_by_is_independent_of_the_caller_supplied_source() {
342 let store = test_store();
343 let v = store
344 .bundle_version_insert("bundle-1", "Name", "body", "human", "{}", "agent-should-not-be-trusted-as-human")
345 .unwrap();
346 assert_eq!(v.source, "human", "source is caller-supplied annotation, kept as-is");
347 assert_eq!(v.written_by, "agent-should-not-be-trusted-as-human", "written_by is the real, separate identity field");
348 }
349}