use agentstategraph::{CommitOptions, Repository};
use agentstategraph_core::IntentCategory;
use chrono::Utc;
use serde_json::Value;
use crate::error::{AsdError, Result};
use crate::paths;
use crate::schema::{ScratchEntry, ScratchStatus};
#[derive(Debug, Default, Clone)]
pub struct ScratchFilter {
pub symbol_id: Option<String>,
pub workflow: Option<String>,
pub session: Option<String>,
pub status: Option<ScratchStatus>,
pub exclude_expired: bool,
}
impl ScratchFilter {
pub fn drafts() -> Self {
Self {
status: Some(ScratchStatus::Draft),
exclude_expired: true,
..Default::default()
}
}
pub fn all() -> Self {
Self {
exclude_expired: false,
..Default::default()
}
}
fn matches(&self, entry: &ScratchEntry) -> bool {
if let Some(ref sid) = self.symbol_id {
if entry.symbol_id.as_deref() != Some(sid.as_str()) {
return false;
}
}
if let Some(ref wf) = self.workflow {
if entry.workflow.as_deref() != Some(wf.as_str()) {
return false;
}
}
if let Some(ref sess) = self.session {
if &entry.session != sess {
return false;
}
}
if let Some(ref st) = self.status {
if &entry.status != st {
return false;
}
}
if self.exclude_expired && entry.is_expired() {
return false;
}
true
}
}
#[derive(Debug, Default, Clone)]
pub struct CleanFilter {
pub older_than: Option<chrono::Duration>,
pub statuses: Vec<ScratchStatus>,
}
impl CleanFilter {
fn matches(&self, entry: &ScratchEntry) -> bool {
if !self.statuses.is_empty() && !self.statuses.contains(&entry.status) {
return false;
}
if let Some(min_age) = self.older_than {
let age = Utc::now() - entry.updated_at;
if age < min_age {
return false;
}
}
true
}
}
pub trait ScratchStore {
fn write_entry(
&self,
ref_name: &str,
entry: &ScratchEntry,
agent_id: &str,
) -> Result<ScratchEntry>;
fn read_entry(&self, ref_name: &str, scratch_id: &str) -> Result<ScratchEntry>;
fn update_entry(
&self,
ref_name: &str,
scratch_id: &str,
content: &str,
agent_id: &str,
) -> Result<ScratchEntry>;
fn list_entries(&self, ref_name: &str, filter: &ScratchFilter) -> Result<Vec<ScratchEntry>>;
fn discard_entry(&self, ref_name: &str, scratch_id: &str, agent_id: &str) -> Result<()>;
fn mark_promoted(
&self,
ref_name: &str,
scratch_id: &str,
ledger_entry_id: &str,
agent_id: &str,
) -> Result<ScratchEntry>;
fn clean_entries(&self, ref_name: &str, filter: &CleanFilter, dry_run: bool) -> Result<usize>;
}
pub struct AsgScratchStore<'a> {
pub repo: &'a Repository,
}
impl<'a> ScratchStore for AsgScratchStore<'a> {
fn write_entry(
&self,
ref_name: &str,
entry: &ScratchEntry,
agent_id: &str,
) -> Result<ScratchEntry> {
let path = paths::scratch_entry_path(&entry.scratch_id);
let val = serde_json::to_value(entry)?;
let opts = CommitOptions::new(
agent_id,
IntentCategory::Checkpoint,
format!("scratch: write {}", entry.scratch_id),
);
self.repo
.set_json(ref_name, &path, &val, opts)
.map_err(|e| AsdError::Other(e.to_string()))?;
Ok(entry.clone())
}
fn read_entry(&self, ref_name: &str, scratch_id: &str) -> Result<ScratchEntry> {
let path = paths::scratch_entry_path(scratch_id);
let val = self
.repo
.get_json(ref_name, &path)
.map_err(|_| AsdError::Other(format!("scratch entry not found: {scratch_id}")))?;
serde_json::from_value(val).map_err(|e| AsdError::Other(e.to_string()))
}
fn update_entry(
&self,
ref_name: &str,
scratch_id: &str,
content: &str,
agent_id: &str,
) -> Result<ScratchEntry> {
let mut entry = self.read_entry(ref_name, scratch_id)?;
if entry.status != ScratchStatus::Draft {
return Err(AsdError::Other(format!(
"scratch entry {} is {:?}, not Draft — cannot update",
scratch_id, entry.status
)));
}
entry.content = content.to_string();
entry.updated_at = Utc::now();
self.write_entry(ref_name, &entry, agent_id)
}
fn list_entries(&self, ref_name: &str, filter: &ScratchFilter) -> Result<Vec<ScratchEntry>> {
let prefix = paths::scratch_root();
let map = match self.repo.get_tree(ref_name, prefix) {
Ok(Value::Object(m)) => m,
Ok(_) | Err(_) => return Ok(Vec::new()),
};
let mut entries: Vec<ScratchEntry> = map
.values()
.filter_map(|v| serde_json::from_value::<ScratchEntry>(v.clone()).ok())
.filter(|e| filter.matches(e))
.collect();
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
fn discard_entry(&self, ref_name: &str, scratch_id: &str, agent_id: &str) -> Result<()> {
let mut entry = self.read_entry(ref_name, scratch_id)?;
entry.status = ScratchStatus::Discarded;
entry.updated_at = Utc::now();
self.write_entry(ref_name, &entry, agent_id)?;
Ok(())
}
fn mark_promoted(
&self,
ref_name: &str,
scratch_id: &str,
ledger_entry_id: &str,
agent_id: &str,
) -> Result<ScratchEntry> {
let mut entry = self.read_entry(ref_name, scratch_id)?;
entry.status = ScratchStatus::Promoted;
entry.promoted_to = Some(ledger_entry_id.to_string());
entry.updated_at = Utc::now();
self.write_entry(ref_name, &entry, agent_id)
}
fn clean_entries(&self, ref_name: &str, filter: &CleanFilter, dry_run: bool) -> Result<usize> {
let all_filter = ScratchFilter::all();
let entries = self.list_entries(ref_name, &all_filter)?;
let to_delete: Vec<ScratchEntry> =
entries.into_iter().filter(|e| filter.matches(e)).collect();
let count = to_delete.len();
if dry_run {
return Ok(count);
}
for entry in &to_delete {
let path = paths::scratch_entry_path(&entry.scratch_id);
let opts = CommitOptions::new(
"asd-clean",
IntentCategory::Refine,
format!("scratch: clean {}", entry.scratch_id),
);
self.repo
.delete(ref_name, &path, opts)
.map_err(|e| AsdError::Other(e.to_string()))?;
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::ScratchStatus;
fn make_entry(content: &str) -> ScratchEntry {
ScratchEntry::new(content, "test-agent")
}
#[test]
fn scratch_entry_new_defaults() {
let e = make_entry("working hypothesis");
assert_eq!(e.status, ScratchStatus::Draft);
assert!(e.promoted_to.is_none());
assert!(e.symbol_id.is_none());
assert!(e.workflow.is_none());
assert!(!e.is_expired());
assert!(e.scratch_id.starts_with("scr_"));
}
#[test]
fn scratch_entry_expired() {
let mut e = make_entry("stale");
e.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
assert!(e.is_expired());
}
#[test]
fn scratch_filter_exclude_expired() {
let mut e = make_entry("expired draft");
e.expires_at = Some(Utc::now() - chrono::Duration::seconds(1));
let filter = ScratchFilter::drafts();
assert!(!filter.matches(&e));
}
#[test]
fn scratch_filter_workflow() {
let mut e = make_entry("note");
e.workflow = Some("tracing-sync-bug".to_string());
let mut filter = ScratchFilter::drafts();
filter.workflow = Some("tracing-sync-bug".to_string());
assert!(filter.matches(&e));
filter.workflow = Some("other-bug".to_string());
assert!(!filter.matches(&e));
}
#[test]
fn clean_filter_status_match() {
let e = make_entry("note");
let filter = CleanFilter {
statuses: vec![ScratchStatus::Discarded, ScratchStatus::Promoted],
older_than: None,
};
assert!(!filter.matches(&e));
}
#[test]
fn clean_filter_age_match() {
let mut e = make_entry("old note");
e.updated_at = Utc::now() - chrono::Duration::hours(2);
let filter = CleanFilter {
statuses: vec![],
older_than: Some(chrono::Duration::hours(1)),
};
assert!(filter.matches(&e));
let filter2 = CleanFilter {
statuses: vec![],
older_than: Some(chrono::Duration::hours(3)),
};
assert!(!filter2.matches(&e));
}
}