use crate::db;
use crate::mcp::param_names;
use crate::models::field_names;
use serde_json::{Value, json};
pub(super) fn handle_archive_list(
conn: &rusqlite::Connection,
params: &Value,
) -> Result<Value, String> {
let namespace = params["namespace"].as_str();
let limit = params["limit"]
.as_u64()
.map_or(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT, |v| {
usize::try_from(v).unwrap_or(usize::MAX)
});
let offset = usize::try_from(params["offset"].as_u64().unwrap_or(0)).unwrap_or(usize::MAX);
let items = db::list_archived(
conn,
namespace,
limit.min(crate::storage::LIST_MAX_LIMIT),
offset,
)
.map_err(|e| e.to_string())?;
Ok(json!({"archived": items, "count": items.len()}))
}
pub(super) fn handle_archive_restore(
conn: &rusqlite::Connection,
params: &Value,
) -> Result<Value, String> {
let id = params["id"]
.as_str()
.ok_or(crate::errors::msg::ID_REQUIRED)?;
crate::validate::validate_id(id).map_err(|e| e.to_string())?;
let restored = db::restore_archived(conn, id).map_err(|e| e.to_string())?;
if !restored {
return Err(crate::errors::msg::NOT_FOUND_IN_ARCHIVE.into());
}
Ok(json!({"restored": true, "id": id}))
}
pub(super) fn handle_archive_purge(
conn: &rusqlite::Connection,
params: &Value,
) -> Result<Value, String> {
let older_than_days = params[param_names::OLDER_THAN_DAYS].as_i64();
let caller = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
.unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
let as_admin = params
.get("as_admin")
.and_then(Value::as_bool)
.unwrap_or(false);
crate::governance::audit::record_decision(
&caller,
"allow",
crate::governance::action_labels::ARCHIVE_PURGE,
"",
json!({
(field_names::OLDER_THAN_DAYS): older_than_days,
(field_names::OWNER_SCOPE): if as_admin { "admin" } else { "caller" },
}),
);
{
use crate::permissions::{Op, PermissionContext, Permissions};
let agent_id = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
.map_err(|e| e.to_string())?;
let ctx = PermissionContext {
op: Op::MemoryArchive,
namespace: crate::DEFAULT_NAMESPACE.to_string(),
agent_id,
payload: json!({
(field_names::OLDER_THAN_DAYS): older_than_days,
"as_admin": as_admin,
}),
};
match Permissions::evaluate(&ctx, &[]) {
crate::permissions::Decision::Allow | crate::permissions::Decision::Modify(_) => {}
crate::permissions::Decision::Deny(reason) => {
return Err(crate::governance::deny_message(
"archive",
crate::governance::DenyGate::PermissionRule,
&reason,
));
}
crate::permissions::Decision::Ask(prompt) => {
return Ok(json!({
"status": "ask",
"reason": prompt,
"action": "archive",
}));
}
}
}
let purged = if as_admin {
db::purge_archive(conn, older_than_days).map_err(|e| e.to_string())?
} else {
db::purge_archive_for_caller(conn, &caller, older_than_days).map_err(|e| e.to_string())?
};
Ok(json!({
"purged": purged,
(field_names::OWNER_SCOPE): if as_admin { "admin" } else { "caller" },
}))
}
pub(super) fn handle_archive_stats(conn: &rusqlite::Connection) -> Result<Value, String> {
db::archive_stats(conn).map_err(|e| e.to_string())
}
pub(super) fn handle_gc(
conn: &rusqlite::Connection,
params: &Value,
archive: bool,
) -> Result<Value, String> {
let dry_run = params["dry_run"].as_bool().unwrap_or(false);
if dry_run {
let now = chrono::Utc::now().to_rfc3339();
let count: usize = conn
.query_row(
"SELECT COUNT(*) FROM memories WHERE expires_at IS NOT NULL AND expires_at < ?1",
rusqlite::params![now],
|r| r.get(0),
)
.unwrap_or(0);
return Ok(json!({"collected": count, "dry_run": true}));
}
let count = db::gc(conn, archive).map_err(|e| e.to_string())?;
Ok(json!({"collected": count, "dry_run": false}))
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct ArchiveListRequest {
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub offset: Option<i64>,
}
#[allow(dead_code)]
pub struct ArchiveListTool;
impl McpTool for ArchiveListTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_ARCHIVE_LIST
}
fn description() -> &'static str {
"List archived (expired) memories."
}
fn docs() -> &'static str {
"List archived memories. Filter by namespace; paginate via offset/limit."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<ArchiveListRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Archive.name()
}
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct ArchivePurgeRequest {
#[serde(default)]
pub older_than_days: Option<i64>,
}
#[allow(dead_code)]
pub struct ArchivePurgeTool;
impl McpTool for ArchivePurgeTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_ARCHIVE_PURGE
}
fn description() -> &'static str {
"Permanently delete archived memories."
}
fn docs() -> &'static str {
"Purge archive. Scope via older_than_days. Unrecoverable."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<ArchivePurgeRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Archive.name()
}
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct ArchiveRestoreRequest {
pub id: String,
}
#[allow(dead_code)]
pub struct ArchiveRestoreTool;
impl McpTool for ArchiveRestoreTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_ARCHIVE_RESTORE
}
fn description() -> &'static str {
"Restore an archived memory back to the active store."
}
fn docs() -> &'static str {
"Restore archived row; expires_at cleared."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<ArchiveRestoreRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Archive.name()
}
}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct ArchiveStatsRequest {}
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct GcRequest {
#[serde(default)]
pub dry_run: Option<bool>,
}
#[allow(dead_code)]
pub struct GcTool;
impl McpTool for GcTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_GC
}
fn description() -> &'static str {
"Trigger garbage collection on expired memories (archives first)."
}
fn docs() -> &'static str {
"GC expired memories. Archives first when archive_on_gc is on (default). dry_run previews."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<GcRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Lifecycle.name()
}
}
#[allow(dead_code)]
pub struct ArchiveStatsTool;
impl McpTool for ArchiveStatsTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_ARCHIVE_STATS
}
fn description() -> &'static str {
"Show archive statistics (total count and per-namespace breakdown)."
}
fn docs() -> &'static str {
"Archive total + per-namespace counts."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<ArchiveStatsRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Archive.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn archive_list_parity_986() {
let derived = derived_props_for::<ArchiveListRequest>();
assert_property_set_parity("memory_archive_list", &derived);
assert_descriptions_match("memory_archive_list", &derived);
}
#[test]
fn archive_list_tool_metadata_986() {
assert_eq!(ArchiveListTool::name(), "memory_archive_list");
assert_eq!(ArchiveListTool::family(), "archive");
}
#[test]
fn archive_purge_parity_986() {
let derived = derived_props_for::<ArchivePurgeRequest>();
assert_property_set_parity("memory_archive_purge", &derived);
assert_descriptions_match("memory_archive_purge", &derived);
}
#[test]
fn archive_purge_tool_metadata_986() {
assert_eq!(ArchivePurgeTool::name(), "memory_archive_purge");
assert_eq!(ArchivePurgeTool::family(), "archive");
}
#[test]
fn archive_restore_parity_986() {
let derived = derived_props_for::<ArchiveRestoreRequest>();
assert_property_set_parity("memory_archive_restore", &derived);
assert_descriptions_match("memory_archive_restore", &derived);
}
#[test]
fn archive_restore_tool_metadata_986() {
assert_eq!(ArchiveRestoreTool::name(), "memory_archive_restore");
assert_eq!(ArchiveRestoreTool::family(), "archive");
}
#[test]
fn archive_stats_parity_986() {
let derived = derived_props_for::<ArchiveStatsRequest>();
assert_property_set_parity("memory_archive_stats", &derived);
assert_descriptions_match("memory_archive_stats", &derived);
}
#[test]
fn archive_stats_tool_metadata_986() {
assert_eq!(ArchiveStatsTool::name(), "memory_archive_stats");
assert_eq!(ArchiveStatsTool::family(), "archive");
}
}
#[cfg(test)]
mod d1_6_987_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn gc_parity_987() {
let derived = derived_props_for::<GcRequest>();
assert_property_set_parity("memory_gc", &derived);
assert_descriptions_match("memory_gc", &derived);
}
#[test]
fn gc_tool_metadata_987() {
assert_eq!(GcTool::name(), "memory_gc");
assert_eq!(GcTool::family(), "lifecycle");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn open_conn() -> rusqlite::Connection {
crate::db::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
#[test]
fn handle_archive_restore_missing_id_errors() {
let conn = open_conn();
let err = handle_archive_restore(&conn, &json!({})).unwrap_err();
assert!(err.contains("id"), "got: {err}");
}
#[test]
fn handle_archive_restore_invalid_id_maps_validator_error() {
let conn = open_conn();
let err = handle_archive_restore(&conn, &json!({"id": "not-a-valid-uuid"})).unwrap_err();
assert!(!err.is_empty(), "expected non-empty validator error");
}
#[test]
fn handle_archive_restore_unknown_uuid_returns_not_found() {
let conn = open_conn();
let err = handle_archive_restore(
&conn,
&json!({"id": "00000000-0000-0000-0000-000000000000"}),
)
.unwrap_err();
assert!(err.contains("not found"), "got: {err}");
}
#[test]
fn handle_archive_list_default_paging_returns_empty() {
let conn = open_conn();
let result = handle_archive_list(&conn, &json!({})).expect("list ok");
assert_eq!(result["count"], 0);
assert!(result["archived"].is_array());
}
#[test]
fn handle_archive_stats_returns_object() {
let conn = open_conn();
let result = handle_archive_stats(&conn).expect("stats ok");
assert!(
result.is_object(),
"archive_stats must return a JSON object on empty DB, got: {result}"
);
}
#[test]
fn handle_gc_dry_run_on_empty_db_returns_zero() {
let conn = open_conn();
let result = handle_gc(&conn, &json!({"dry_run": true}), false).expect("gc dry-run ok");
assert_eq!(result["collected"], 0);
assert_eq!(result["dry_run"], true);
}
#[test]
fn handle_gc_actual_run_on_empty_db_returns_zero() {
let conn = open_conn();
let result = handle_gc(&conn, &json!({"dry_run": false}), true).expect("gc run ok");
assert_eq!(result["collected"], 0);
assert_eq!(result["dry_run"], false);
}
#[test]
fn handle_archive_purge_default_no_filter_succeeds_on_empty_db() {
let conn = open_conn();
let result = handle_archive_purge(&conn, &json!({})).expect("purge ok");
let purged = &result["purged"];
assert!(
purged.is_number(),
"expected numeric `purged`, got: {purged}"
);
}
}