agentstatedeveloper_core/
scratch.rs1use agentstategraph::{CommitOptions, Repository};
23use agentstategraph_core::IntentCategory;
24use chrono::Utc;
25use serde_json::Value;
26
27use crate::error::{AsdError, Result};
28use crate::paths;
29use crate::schema::{ScratchEntry, ScratchStatus};
30
31#[derive(Debug, Default, Clone)]
38pub struct ScratchFilter {
39 pub symbol_id: Option<String>,
41 pub workflow: Option<String>,
43 pub session: Option<String>,
45 pub status: Option<ScratchStatus>,
47 pub exclude_expired: bool,
49}
50
51impl ScratchFilter {
52 pub fn drafts() -> Self {
54 Self {
55 status: Some(ScratchStatus::Draft),
56 exclude_expired: true,
57 ..Default::default()
58 }
59 }
60
61 pub fn all() -> Self {
63 Self {
64 exclude_expired: false,
65 ..Default::default()
66 }
67 }
68
69 fn matches(&self, entry: &ScratchEntry) -> bool {
70 if let Some(ref sid) = self.symbol_id {
71 if entry.symbol_id.as_deref() != Some(sid.as_str()) {
72 return false;
73 }
74 }
75 if let Some(ref wf) = self.workflow {
76 if entry.workflow.as_deref() != Some(wf.as_str()) {
77 return false;
78 }
79 }
80 if let Some(ref sess) = self.session {
81 if &entry.session != sess {
82 return false;
83 }
84 }
85 if let Some(ref st) = self.status {
86 if &entry.status != st {
87 return false;
88 }
89 }
90 if self.exclude_expired && entry.is_expired() {
91 return false;
92 }
93 true
94 }
95}
96
97#[derive(Debug, Default, Clone)]
100pub struct CleanFilter {
101 pub older_than: Option<chrono::Duration>,
103 pub statuses: Vec<ScratchStatus>,
105}
106
107impl CleanFilter {
108 fn matches(&self, entry: &ScratchEntry) -> bool {
109 if !self.statuses.is_empty() && !self.statuses.contains(&entry.status) {
111 return false;
112 }
113 if let Some(min_age) = self.older_than {
115 let age = Utc::now() - entry.updated_at;
116 if age < min_age {
117 return false;
118 }
119 }
120 true
121 }
122}
123
124pub trait ScratchStore {
134 fn write_entry(
137 &self,
138 ref_name: &str,
139 entry: &ScratchEntry,
140 agent_id: &str,
141 ) -> Result<ScratchEntry>;
142
143 fn read_entry(&self, ref_name: &str, scratch_id: &str) -> Result<ScratchEntry>;
145
146 fn update_entry(
148 &self,
149 ref_name: &str,
150 scratch_id: &str,
151 content: &str,
152 agent_id: &str,
153 ) -> Result<ScratchEntry>;
154
155 fn list_entries(&self, ref_name: &str, filter: &ScratchFilter) -> Result<Vec<ScratchEntry>>;
157
158 fn discard_entry(&self, ref_name: &str, scratch_id: &str, agent_id: &str) -> Result<()>;
161
162 fn mark_promoted(
165 &self,
166 ref_name: &str,
167 scratch_id: &str,
168 ledger_entry_id: &str,
169 agent_id: &str,
170 ) -> Result<ScratchEntry>;
171
172 fn clean_entries(&self, ref_name: &str, filter: &CleanFilter, dry_run: bool) -> Result<usize>;
175}
176
177pub struct AsgScratchStore<'a> {
183 pub repo: &'a Repository,
184}
185
186impl<'a> ScratchStore for AsgScratchStore<'a> {
187 fn write_entry(
188 &self,
189 ref_name: &str,
190 entry: &ScratchEntry,
191 agent_id: &str,
192 ) -> Result<ScratchEntry> {
193 let path = paths::scratch_entry_path(&entry.scratch_id);
194 let val = serde_json::to_value(entry)?;
195 let opts = CommitOptions::new(
196 agent_id,
197 IntentCategory::Checkpoint,
198 format!("scratch: write {}", entry.scratch_id),
199 );
200 self.repo
201 .set_json(ref_name, &path, &val, opts)
202 .map_err(|e| AsdError::Other(e.to_string()))?;
203 Ok(entry.clone())
204 }
205
206 fn read_entry(&self, ref_name: &str, scratch_id: &str) -> Result<ScratchEntry> {
207 let path = paths::scratch_entry_path(scratch_id);
208 let val = self
209 .repo
210 .get_json(ref_name, &path)
211 .map_err(|_| AsdError::Other(format!("scratch entry not found: {scratch_id}")))?;
212 serde_json::from_value(val).map_err(|e| AsdError::Other(e.to_string()))
213 }
214
215 fn update_entry(
216 &self,
217 ref_name: &str,
218 scratch_id: &str,
219 content: &str,
220 agent_id: &str,
221 ) -> Result<ScratchEntry> {
222 let mut entry = self.read_entry(ref_name, scratch_id)?;
223 if entry.status != ScratchStatus::Draft {
224 return Err(AsdError::Other(format!(
225 "scratch entry {} is {:?}, not Draft — cannot update",
226 scratch_id, entry.status
227 )));
228 }
229 entry.content = content.to_string();
230 entry.updated_at = Utc::now();
231 self.write_entry(ref_name, &entry, agent_id)
232 }
233
234 fn list_entries(&self, ref_name: &str, filter: &ScratchFilter) -> Result<Vec<ScratchEntry>> {
235 let prefix = paths::scratch_root();
236 let map = match self.repo.get_tree(ref_name, prefix) {
237 Ok(Value::Object(m)) => m,
238 Ok(_) | Err(_) => return Ok(Vec::new()),
239 };
240 let mut entries: Vec<ScratchEntry> = map
241 .values()
242 .filter_map(|v| serde_json::from_value::<ScratchEntry>(v.clone()).ok())
243 .filter(|e| filter.matches(e))
244 .collect();
245 entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
247 Ok(entries)
248 }
249
250 fn discard_entry(&self, ref_name: &str, scratch_id: &str, agent_id: &str) -> Result<()> {
251 let mut entry = self.read_entry(ref_name, scratch_id)?;
252 entry.status = ScratchStatus::Discarded;
253 entry.updated_at = Utc::now();
254 self.write_entry(ref_name, &entry, agent_id)?;
255 Ok(())
256 }
257
258 fn mark_promoted(
259 &self,
260 ref_name: &str,
261 scratch_id: &str,
262 ledger_entry_id: &str,
263 agent_id: &str,
264 ) -> Result<ScratchEntry> {
265 let mut entry = self.read_entry(ref_name, scratch_id)?;
266 entry.status = ScratchStatus::Promoted;
267 entry.promoted_to = Some(ledger_entry_id.to_string());
268 entry.updated_at = Utc::now();
269 self.write_entry(ref_name, &entry, agent_id)
270 }
271
272 fn clean_entries(&self, ref_name: &str, filter: &CleanFilter, dry_run: bool) -> Result<usize> {
273 let all_filter = ScratchFilter::all();
274 let entries = self.list_entries(ref_name, &all_filter)?;
275 let to_delete: Vec<ScratchEntry> =
276 entries.into_iter().filter(|e| filter.matches(e)).collect();
277 let count = to_delete.len();
278 if dry_run {
279 return Ok(count);
280 }
281 for entry in &to_delete {
282 let path = paths::scratch_entry_path(&entry.scratch_id);
283 let opts = CommitOptions::new(
284 "asd-clean",
285 IntentCategory::Refine,
286 format!("scratch: clean {}", entry.scratch_id),
287 );
288 self.repo
289 .delete(ref_name, &path, opts)
290 .map_err(|e| AsdError::Other(e.to_string()))?;
291 }
292 Ok(count)
293 }
294}
295
296#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::schema::ScratchStatus;
304
305 fn make_entry(content: &str) -> ScratchEntry {
306 ScratchEntry::new(content, "test-agent")
307 }
308
309 #[test]
310 fn scratch_entry_new_defaults() {
311 let e = make_entry("working hypothesis");
312 assert_eq!(e.status, ScratchStatus::Draft);
313 assert!(e.promoted_to.is_none());
314 assert!(e.symbol_id.is_none());
315 assert!(e.workflow.is_none());
316 assert!(!e.is_expired());
317 assert!(e.scratch_id.starts_with("scr_"));
318 }
319
320 #[test]
321 fn scratch_entry_expired() {
322 let mut e = make_entry("stale");
323 e.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
324 assert!(e.is_expired());
325 }
326
327 #[test]
328 fn scratch_filter_exclude_expired() {
329 let mut e = make_entry("expired draft");
330 e.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
331 let filter = ScratchFilter::drafts();
332 assert!(!filter.matches(&e));
333 }
334
335 #[test]
336 fn scratch_filter_workflow() {
337 let mut e = make_entry("note");
338 e.workflow = Some("tracing-sync-bug".to_string());
339 let mut filter = ScratchFilter::drafts();
340 filter.workflow = Some("tracing-sync-bug".to_string());
341 assert!(filter.matches(&e));
342 filter.workflow = Some("other-bug".to_string());
343 assert!(!filter.matches(&e));
344 }
345
346 #[test]
347 fn clean_filter_status_match() {
348 let e = make_entry("note");
349 let filter = CleanFilter {
351 statuses: vec![ScratchStatus::Discarded, ScratchStatus::Promoted],
352 older_than: None,
353 };
354 assert!(!filter.matches(&e));
355 }
356
357 #[test]
358 fn clean_filter_age_match() {
359 let mut e = make_entry("old note");
360 e.updated_at = Utc::now() - chrono::Duration::hours(2);
362 let filter = CleanFilter {
363 statuses: vec![],
364 older_than: Some(chrono::Duration::hours(1)),
365 };
366 assert!(filter.matches(&e));
367
368 let filter2 = CleanFilter {
370 statuses: vec![],
371 older_than: Some(chrono::Duration::hours(3)),
372 };
373 assert!(!filter2.matches(&e));
374 }
375}