use chrono::Utc;
use agentstategraph_core::intent::{AgentId, IntentId, SessionId};
use agentstategraph_core::namespace::Namespace;
use agentstategraph_core::object::ObjectId;
pub use agentstategraph_core::session::{Session, SessionStatus};
#[allow(unused_imports)]
use agentstategraph_storage::SessionStore;
use agentstategraph_storage::{Storage, StorageError};
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("session not found: {0}")]
NotFound(String),
#[error("path '{path}' is outside session scope '{scope}'")]
OutOfScope { path: String, scope: String },
#[error("storage error: {0}")]
Storage(#[from] StorageError),
}
#[derive(Default)]
pub struct CreateSessionParams {
pub parent_session: Option<SessionId>,
pub delegated_intent: Option<IntentId>,
pub report_to: Option<String>,
pub path_scope: Option<String>,
pub scope_namespace: Option<Namespace>,
}
pub struct SessionManager<'a> {
storage: &'a dyn Storage,
}
impl<'a> SessionManager<'a> {
pub fn new(storage: &'a dyn Storage) -> Self {
Self { storage }
}
pub fn create(
&self,
agent_id: &str,
working_branch: &str,
head: ObjectId,
params: CreateSessionParams,
) -> Result<Session, SessionError> {
let id = uuid::Uuid::new_v4().to_string();
let session = Session {
id: id.clone(),
agent_id: agent_id.to_string(),
working_branch: working_branch.to_string(),
head,
parent_session: params.parent_session,
delegated_intent: params.delegated_intent,
report_to: params.report_to,
path_scope: params.path_scope,
scope_tenant: None,
scope_namespace: params.scope_namespace,
status: SessionStatus::Active,
created_at: Utc::now(),
ended_at: None,
};
self.storage.create_session(&session)?;
Ok(session)
}
pub fn get(&self, id: &str) -> Result<Option<Session>, SessionError> {
Ok(self.storage.get_session(id)?)
}
pub fn update_head(&self, id: &str, head: ObjectId) -> Result<(), SessionError> {
let mut session = self
.storage
.get_session(id)?
.ok_or_else(|| SessionError::NotFound(id.to_string()))?;
session.head = head;
self.storage.create_session(&session)?;
Ok(())
}
pub fn list(&self, agent_filter: Option<&str>) -> Result<Vec<Session>, SessionError> {
Ok(self.storage.list_sessions(agent_filter)?)
}
pub fn children(&self, parent_id: &str) -> Result<Vec<Session>, SessionError> {
let all = self.storage.list_sessions(None)?;
Ok(all
.into_iter()
.filter(|s| s.parent_session.as_deref() == Some(parent_id))
.collect())
}
pub fn end(&self, id: &str, status: SessionStatus) -> Result<(), SessionError> {
self.storage.end_session(id, status, Utc::now())?;
Ok(())
}
pub fn count(&self) -> Result<usize, SessionError> {
Ok(self.storage.list_sessions(None)?.len())
}
}
pub fn check_scope(session: &Session, path: &str) -> Result<(), SessionError> {
if let Some(ref scope) = session.path_scope
&& !path.starts_with(scope)
{
return Err(SessionError::OutOfScope {
path: path.to_string(),
scope: scope.clone(),
});
}
Ok(())
}
impl SessionManager<'_> {
pub fn check_scope(session: &Session, path: &str) -> Result<(), SessionError> {
check_scope(session, path)
}
}
#[doc(hidden)]
pub fn _sanity_agent_id() -> AgentId {
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
use agentstategraph_storage::SqliteStorage;
fn mgr_store() -> SqliteStorage {
SqliteStorage::in_memory().expect("in-memory sqlite")
}
pub fn make_session(id: &str, agent_id: &str) -> Session {
Session {
id: id.to_string(),
agent_id: agent_id.to_string(),
working_branch: format!("agents/{}/workspace", agent_id),
head: agentstategraph_core::ObjectId::hash(id.as_bytes()),
parent_session: None,
delegated_intent: None,
report_to: None,
path_scope: None,
scope_tenant: None,
scope_namespace: None,
status: SessionStatus::Active,
created_at: Utc::now(),
ended_at: None,
}
}
#[test]
fn test_create_and_get_session() {
let store = mgr_store();
let mgr = SessionManager::new(&store);
let session = mgr
.create(
"agent/planner",
"agents/planner/workspace",
ObjectId::hash(b"head"),
CreateSessionParams::default(),
)
.unwrap();
let retrieved = mgr.get(&session.id).unwrap().unwrap();
assert_eq!(retrieved.agent_id, "agent/planner");
}
#[test]
fn test_parent_child_sessions() {
let store = mgr_store();
let mgr = SessionManager::new(&store);
let parent = mgr
.create(
"agent/orchestrator",
"agents/orchestrator/workspace",
ObjectId::hash(b"head"),
CreateSessionParams {
delegated_intent: Some("intent-001".to_string()),
..Default::default()
},
)
.unwrap();
mgr.create(
"agent/storage",
"agents/storage/workspace",
ObjectId::hash(b"head"),
CreateSessionParams {
parent_session: Some(parent.id.clone()),
delegated_intent: Some("intent-002".to_string()),
report_to: Some("agent/orchestrator".to_string()),
path_scope: Some("/config/storage".to_string()),
..Default::default()
},
)
.unwrap();
mgr.create(
"agent/network",
"agents/network/workspace",
ObjectId::hash(b"head"),
CreateSessionParams {
parent_session: Some(parent.id.clone()),
delegated_intent: Some("intent-003".to_string()),
report_to: Some("agent/orchestrator".to_string()),
path_scope: Some("/config/network".to_string()),
..Default::default()
},
)
.unwrap();
let children = mgr.children(&parent.id).unwrap();
assert_eq!(children.len(), 2);
}
#[test]
fn test_path_scope_enforcement() {
let session = Session {
id: "test".to_string(),
agent_id: "agent/storage".to_string(),
working_branch: "agents/storage/workspace".to_string(),
head: ObjectId::hash(b"head"),
parent_session: None,
delegated_intent: None,
report_to: None,
path_scope: Some("/config/storage".to_string()),
scope_tenant: None,
scope_namespace: None,
status: SessionStatus::Active,
created_at: Utc::now(),
ended_at: None,
};
assert!(check_scope(&session, "/config/storage/type").is_ok());
assert!(check_scope(&session, "/config/storage").is_ok());
assert!(check_scope(&session, "/config/network/subnet").is_err());
assert!(check_scope(&session, "/nodes/0").is_err());
}
#[test]
fn test_no_scope_allows_all() {
let session = Session {
id: "test".to_string(),
agent_id: "agent/admin".to_string(),
working_branch: "main".to_string(),
head: ObjectId::hash(b"head"),
parent_session: None,
delegated_intent: None,
report_to: None,
path_scope: None,
scope_tenant: None,
scope_namespace: None,
status: SessionStatus::Active,
created_at: Utc::now(),
ended_at: None,
};
assert!(check_scope(&session, "/anything/at/all").is_ok());
}
#[test]
fn test_list_by_agent() {
let store = mgr_store();
let mgr = SessionManager::new(&store);
mgr.create(
"agent/a",
"br/a",
ObjectId::hash(b"h"),
CreateSessionParams::default(),
)
.unwrap();
mgr.create(
"agent/b",
"br/b",
ObjectId::hash(b"h"),
CreateSessionParams::default(),
)
.unwrap();
mgr.create(
"agent/a",
"br/a2",
ObjectId::hash(b"h"),
CreateSessionParams::default(),
)
.unwrap();
assert_eq!(mgr.list(Some("agent/a")).unwrap().len(), 2);
assert_eq!(mgr.list(Some("agent/b")).unwrap().len(), 1);
assert_eq!(mgr.list(None).unwrap().len(), 3);
}
}