Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,6 @@ trigger_labels = ["coven:fix", "coven:review"] # Labels that trigger this famil
# [memory]
# enabled = false
# approval_required = true # learned facts stay pending until a maintainer approves
# retention_days = 365
# retention_days = 365 # forwarded to the runtime; also expires the adapter's memory audit
# [memory.repos."acme/billing"]
# enabled = true # per-repo opt-in
21 changes: 21 additions & 0 deletions crates/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ async fn main() -> Result<()> {
worker::run(worker_config, worker_store, worker_notify).await;
});

// Memory retention sweep (issue #6): when a retention horizon is
// configured, periodically expire audit rows older than it. The
// first tick fires immediately, so a stale audit is trimmed at boot.
if let Some(retention_days) = config.memory.retention_days {
let sweep_store = store.clone();
tokio::spawn(async move {
let mut ticker =
tokio::time::interval(std::time::Duration::from_secs(6 * 3600));
loop {
ticker.tick().await;
match sweep_store.expire_memory_activity(retention_days).await {
Ok(0) => {}
Ok(expired) => {
tracing::info!(expired, "expired memory activity past retention")
}
Err(e) => tracing::error!("memory retention sweep failed: {e:#}"),
}
}
});
}

// Build router.
let state = AppState {
config: config.clone(),
Expand Down
63 changes: 62 additions & 1 deletion crates/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,12 +608,19 @@ impl Store {
let mut conn = conn.lock().expect("store mutex poisoned");
let tx = conn.transaction()?;
for row in &rows {
// Honor an explicit timestamp when provided (tests/backfills);
// otherwise stamp the current time.
let at = if row.at.is_empty() {
now_rfc3339()
} else {
row.at.clone()
};
Comment on lines +611 to +617
tx.execute(
"INSERT INTO memory_activity
(at, installation_id, repo, task_id, op, target, scope, outcome)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
now_rfc3339(),
at,
row.installation_id,
row.repo,
row.task_id,
Expand Down Expand Up @@ -739,6 +746,30 @@ impl Store {
.await
.expect("store task panicked")
}

/// Deletes memory-activity audit rows older than `cutoff` (RFC 3339) —
/// retention expiry (issue #6). Revocations are active policy and are never
/// expired: an expired revocation would silently un-revoke memory.
pub async fn expire_memory_activity_before(&self, cutoff: &str) -> Result<usize> {
let conn = self.conn.clone();
let cutoff = cutoff.to_string();
tokio::task::spawn_blocking(move || {
let conn = conn.lock().expect("store mutex poisoned");
let n = conn.execute(
"DELETE FROM memory_activity WHERE at < ?1",
params![cutoff],
)?;
Comment on lines +757 to +761
Comment on lines +753 to +761
Ok(n)
})
.await
.expect("store task panicked")
}

/// Deletes memory-activity audit rows older than `retention_days` from now.
pub async fn expire_memory_activity(&self, retention_days: u32) -> Result<usize> {
let cutoff = (chrono::Utc::now() - chrono::Duration::days(retention_days as i64)).to_rfc3339();
self.expire_memory_activity_before(&cutoff).await
}
}

/// One recorded memory operation for the inspect/audit trail (issue #6).
Expand Down Expand Up @@ -1099,6 +1130,36 @@ mod tests {
assert!(store.revocations_for(2, "OpenCoven/billing").await.unwrap().is_empty());
}

#[tokio::test]
async fn retention_expiry_removes_old_activity_but_keeps_revocations() {
let store = Store::open_in_memory().unwrap();
// An old audit row (explicit timestamp) and a fresh one (empty → now).
let mut old = memory_row(1, "OpenCoven/a", "repo/OpenCoven/a/old", "accepted");
old.at = "2020-01-01T00:00:00+00:00".to_string();
store.record_memory_activity(vec![old]).await.unwrap();
store
.record_memory_activity(vec![memory_row(1, "OpenCoven/a", "repo/OpenCoven/a/new", "accepted")])
.await
.unwrap();
// A revocation must survive the sweep.
store.record_revocation(1, "OpenCoven/a", "repo/OpenCoven/a/x").await.unwrap();

let removed = store
.expire_memory_activity_before("2021-01-01T00:00:00+00:00")
.await
.unwrap();
assert_eq!(removed, 1, "only the 2020 row expires");

let remaining = store.list_memory(None).await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].target, "repo/OpenCoven/a/new");
// Revocation is untouched — expiring it would un-revoke memory.
assert_eq!(
store.revocations_for(1, "OpenCoven/a").await.unwrap(),
vec!["repo/OpenCoven/a/x"]
);
}

#[tokio::test]
async fn delete_on_uninstall_purges_activity_and_revocations() {
let store = Store::open_in_memory().unwrap();
Expand Down
6 changes: 3 additions & 3 deletions docs/memory-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,9 @@ values are sane.
prefix-addressable keys; `POST /api/github/memory/revoke` with adapter-side
enforcement (revoked keys refused on future reads/writes) plus a denial list
forwarded to the runtime; `delete_on_uninstall`; read citation on the review
surface. **Done**, except **retention expiry**, which needs a periodic sweep
(an operational enhancement, not a contract requirement) and remains a
follow-up.
surface; and **retention expiry**a periodic server sweep drops audit rows
past `retention_days` (revocations are never expired), while the same horizon
is forwarded to the runtime for the memory bytes themselves. **Done.**

The adapter side is complete. What is inherently bilateral — the runtime
honoring the `denied` list and physically deleting revoked bytes — is the
Expand Down
Loading