Skip to main content

agentstategraph_core/
session.rs

1//! Sessions — durable records of sub-agent working contexts.
2//!
3//! Sessions formalize the parent-child agent relationship: a lead agent
4//! delegates work by creating scoped sessions for sub-agents, each with
5//! their own branch and (optionally) restricted path access.
6//!
7//! The type lives in `agentstategraph-core` so storage backends can
8//! implement `SessionStore` without circular dependencies on the repo
9//! crate. The `SessionManager` in `agentstategraph` wraps these records
10//! with enforcement helpers (e.g. `check_scope`).
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::intent::{AgentId, IntentId, SessionId};
16use crate::namespace::Namespace;
17use crate::object::ObjectId;
18
19/// Lifecycle state of a persisted session.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum SessionStatus {
22    /// The session is in-flight; commits may be associated with it.
23    Active,
24    /// The session finished its delegated work successfully.
25    Completed,
26    /// The session was torn down without completing (timeout, cancel, etc).
27    Abandoned,
28}
29
30impl SessionStatus {
31    /// Wire-form string used by storage backends.
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Self::Active => "Active",
35            Self::Completed => "Completed",
36            Self::Abandoned => "Abandoned",
37        }
38    }
39
40    /// Parse the wire form. Unknown values map to `Abandoned` so a row
41    /// written by a newer binary never panics an older reader.
42    pub fn from_wire(s: &str) -> Self {
43        match s {
44            "Active" => Self::Active,
45            "Completed" => Self::Completed,
46            _ => Self::Abandoned,
47        }
48    }
49}
50
51/// A durable agent session record.
52///
53/// `head` tracks the current tip of the session's working branch; it's
54/// updated by `SessionManager::update_head`. All other fields are
55/// set at creation and frozen except for `status` / `ended_at` which
56/// are updated by `end_session`.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Session {
59    pub id: SessionId,
60    pub agent_id: AgentId,
61    pub working_branch: String,
62    pub head: ObjectId,
63    /// Who spawned this session (the parent session id, if any).
64    pub parent_session: Option<SessionId>,
65    /// The intent this session was created to fulfill.
66    pub delegated_intent: Option<IntentId>,
67    /// Who to report back to.
68    pub report_to: Option<String>,
69    /// Path scope restriction (if set, the agent can only modify paths
70    /// under this prefix).
71    pub path_scope: Option<String>,
72    /// Tenant scope restriction. When set, `PolicyStore::evaluate` /
73    /// `evaluate_change` consulted with a `tenant_filter` tied to this
74    /// session consult only policies whose `tenant_id` matches or is
75    /// `None` (global). Added in 0.7.5-beta.1 §3a. `None` = no tenant
76    /// scoping, same behaviour as pre-0.7.5.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub scope_tenant: Option<String>,
79    /// Namespace scope. When set, this session's Repository will use this
80    /// namespace for all ref operations, overriding the Repository's
81    /// configured default namespace. `None` = use the Repository default.
82    /// Added alongside the namespace primitive (§namespace).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub scope_namespace: Option<Namespace>,
85    /// Lifecycle status.
86    #[serde(default = "default_status")]
87    pub status: SessionStatus,
88    /// When this session was created.
89    pub created_at: DateTime<Utc>,
90    /// When this session ended (None while Active).
91    #[serde(default)]
92    pub ended_at: Option<DateTime<Utc>>,
93}
94
95fn default_status() -> SessionStatus {
96    SessionStatus::Active
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    // --- SessionStatus::as_str ---
104
105    #[test]
106    fn as_str_active() {
107        assert_eq!(SessionStatus::Active.as_str(), "Active");
108    }
109
110    #[test]
111    fn as_str_completed() {
112        assert_eq!(SessionStatus::Completed.as_str(), "Completed");
113    }
114
115    #[test]
116    fn as_str_abandoned() {
117        assert_eq!(SessionStatus::Abandoned.as_str(), "Abandoned");
118    }
119
120    // --- SessionStatus::from_wire ---
121
122    #[test]
123    fn from_wire_known_values() {
124        assert_eq!(SessionStatus::from_wire("Active"), SessionStatus::Active);
125        assert_eq!(
126            SessionStatus::from_wire("Completed"),
127            SessionStatus::Completed
128        );
129        assert_eq!(
130            SessionStatus::from_wire("Abandoned"),
131            SessionStatus::Abandoned
132        );
133    }
134
135    #[test]
136    fn from_wire_unknown_maps_to_abandoned() {
137        assert_eq!(
138            SessionStatus::from_wire("unknown-future-status"),
139            SessionStatus::Abandoned
140        );
141        assert_eq!(SessionStatus::from_wire(""), SessionStatus::Abandoned);
142        assert_eq!(
143            SessionStatus::from_wire("active"), // wrong case
144            SessionStatus::Abandoned
145        );
146    }
147
148    // --- as_str → from_wire round-trip ---
149
150    #[test]
151    fn as_str_from_wire_roundtrip() {
152        for s in [
153            SessionStatus::Active,
154            SessionStatus::Completed,
155            SessionStatus::Abandoned,
156        ] {
157            assert_eq!(SessionStatus::from_wire(s.as_str()), s);
158        }
159    }
160
161    // --- SessionStatus serialization ---
162
163    #[test]
164    fn session_status_serializes_and_deserializes() {
165        for s in [
166            SessionStatus::Active,
167            SessionStatus::Completed,
168            SessionStatus::Abandoned,
169        ] {
170            let j = serde_json::to_string(&s).unwrap();
171            let back: SessionStatus = serde_json::from_str(&j).unwrap();
172            assert_eq!(back, s);
173        }
174    }
175
176    // --- Session optional fields ---
177
178    #[test]
179    fn session_optional_fields_skip_when_none() {
180        let s = Session {
181            id: "s1".into(),
182            agent_id: "agent/test".into(),
183            working_branch: "main".into(),
184            head: ObjectId::hash(b"head"),
185            parent_session: None,
186            delegated_intent: None,
187            report_to: None,
188            path_scope: None,
189            scope_tenant: None,
190            scope_namespace: None,
191            status: SessionStatus::Active,
192            created_at: Utc::now(),
193            ended_at: None,
194        };
195
196        let json = serde_json::to_string(&s).unwrap();
197        assert!(
198            !json.contains("scope_tenant"),
199            "scope_tenant should be omitted when None (has skip_serializing_if)"
200        );
201        // ended_at uses #[serde(default)] without skip_serializing_if, so it serializes as null
202        assert!(
203            json.contains("ended_at"),
204            "ended_at is always present in JSON (serializes as null when None)"
205        );
206    }
207
208    #[test]
209    fn session_round_trip_with_all_fields() {
210        let s = Session {
211            id: "sess-42".into(),
212            agent_id: "agent/planner".into(),
213            working_branch: "feature/planning".into(),
214            head: ObjectId::hash(b"some-commit"),
215            parent_session: Some("sess-1".into()),
216            delegated_intent: Some("intent-7".into()),
217            report_to: Some("lead/coordinator".into()),
218            path_scope: Some("/cluster/nodes".into()),
219            scope_tenant: Some("tenant-A".into()),
220            scope_namespace: Some(Namespace::new("project-x").unwrap()),
221            status: SessionStatus::Completed,
222            created_at: Utc::now(),
223            ended_at: Some(Utc::now()),
224        };
225
226        let json = serde_json::to_string(&s).unwrap();
227        let restored: Session = serde_json::from_str(&json).unwrap();
228
229        assert_eq!(restored.id, s.id);
230        assert_eq!(restored.agent_id, s.agent_id);
231        assert_eq!(restored.working_branch, s.working_branch);
232        assert_eq!(restored.status, SessionStatus::Completed);
233        assert_eq!(restored.parent_session, s.parent_session);
234        assert_eq!(restored.scope_tenant.as_deref(), Some("tenant-A"));
235        assert_eq!(
236            restored.scope_namespace.as_ref().map(|n| n.as_str()),
237            Some("project-x")
238        );
239        assert!(restored.ended_at.is_some());
240    }
241}