Skip to main content

agentstatedeveloper_core/
scratch.rs

1//! Scratchpad store for ephemeral agent working notes.
2//!
3//! Scratch entries are stored locally at `/asd/v1/scratch/<scratch_id>`
4//! and are **not** synced to the sidecar or subject to the policy gate.
5//!
6//! ## Lifecycle
7//!
8//! ```text
9//! scratch_write  →  Draft
10//! scratch_update →  Draft   (content updated, updated_at refreshed)
11//! scratch_discard →  Discarded
12//! scratch_promote → Promoted  (ledger entry created first; pass entry_id here)
13//! scratch_clean  → permanent delete of Discarded/Promoted older than filter
14//! ```
15//!
16//! ## Filtering
17//!
18//! [`ScratchFilter`] is OR-free: every `Some` field must match (AND semantics).
19//! `exclude_expired` defaults to `true` — expired drafts are hidden unless
20//! the caller opts in.
21
22use 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// ---------------------------------------------------------------------------
32// Filter types
33// ---------------------------------------------------------------------------
34
35/// Predicate applied by [`ScratchStore::list_entries`].
36/// All `Some` fields must match (AND semantics).
37#[derive(Debug, Default, Clone)]
38pub struct ScratchFilter {
39    /// Restrict to entries scoped to this symbol_id.
40    pub symbol_id: Option<String>,
41    /// Restrict to entries with this workflow name.
42    pub workflow: Option<String>,
43    /// Restrict to entries written by this session/agent_id.
44    pub session: Option<String>,
45    /// Restrict to entries with this status. `None` means "any status".
46    pub status: Option<ScratchStatus>,
47    /// When `true` (default), hide entries whose `expires_at` is in the past.
48    pub exclude_expired: bool,
49}
50
51impl ScratchFilter {
52    /// A filter that returns only live (non-expired) draft entries.
53    pub fn drafts() -> Self {
54        Self {
55            status: Some(ScratchStatus::Draft),
56            exclude_expired: true,
57            ..Default::default()
58        }
59    }
60
61    /// A filter that returns everything (no restrictions).
62    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/// Predicate used by [`ScratchStore::clean_entries`] to select entries
98/// for permanent deletion.
99#[derive(Debug, Default, Clone)]
100pub struct CleanFilter {
101    /// Only delete entries older than this duration (measured from `updated_at`).
102    pub older_than: Option<chrono::Duration>,
103    /// Delete entries whose status is in this list. Empty = all statuses.
104    pub statuses: Vec<ScratchStatus>,
105}
106
107impl CleanFilter {
108    fn matches(&self, entry: &ScratchEntry) -> bool {
109        // Status check.
110        if !self.statuses.is_empty() && !self.statuses.contains(&entry.status) {
111            return false;
112        }
113        // Age check.
114        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
124// ---------------------------------------------------------------------------
125// Trait
126// ---------------------------------------------------------------------------
127
128/// Scratch-entry CRUD operations.
129///
130/// All methods are direct (no policy gate). Only `mark_promoted` is expected
131/// to be called after the caller has already created a [`LedgerEntry`] via
132/// the policy-gated ledger store.
133pub trait ScratchStore {
134    /// Persist a new scratch entry. Returns the stored entry (may have
135    /// `updated_at` refreshed by the impl).
136    fn write_entry(
137        &self,
138        ref_name: &str,
139        entry: &ScratchEntry,
140        agent_id: &str,
141    ) -> Result<ScratchEntry>;
142
143    /// Read a single entry by scratch_id.
144    fn read_entry(&self, ref_name: &str, scratch_id: &str) -> Result<ScratchEntry>;
145
146    /// Replace the content of an existing draft entry and refresh `updated_at`.
147    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    /// List entries matching the filter, newest first.
156    fn list_entries(&self, ref_name: &str, filter: &ScratchFilter) -> Result<Vec<ScratchEntry>>;
157
158    /// Transition status to `Discarded`. No data is deleted; the entry remains
159    /// until `clean_entries` removes it.
160    fn discard_entry(&self, ref_name: &str, scratch_id: &str, agent_id: &str) -> Result<()>;
161
162    /// Transition status to `Promoted` and record which ledger entry it became.
163    /// The caller is responsible for creating the ledger entry first.
164    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    /// Permanently delete entries matching the filter. Returns the number
173    /// of entries removed (0 when `dry_run` is true).
174    fn clean_entries(&self, ref_name: &str, filter: &CleanFilter, dry_run: bool) -> Result<usize>;
175}
176
177// ---------------------------------------------------------------------------
178// ASG-backed implementation
179// ---------------------------------------------------------------------------
180
181/// [`ScratchStore`] backed by an ASG [`Repository`].
182pub 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        // Newest first.
246        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// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[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        // Draft entry should not match a filter for Discarded+Promoted.
350        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        // Backdate updated_at by 2 hours.
361        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        // Too young:
369        let filter2 = CleanFilter {
370            statuses: vec![],
371            older_than: Some(chrono::Duration::hours(3)),
372        };
373        assert!(!filter2.matches(&e));
374    }
375}