Skip to main content

zeph_core/goal/
registry.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared registry for active autonomous goal sessions.
5//!
6//! [`AutonomousRegistry`] is an `Arc<Mutex<_>>` wrapper around a `HashMap` keyed by goal ID.
7//! It is cloned into the `/agents` fleet view handler so it can read session state without
8//! borrowing `Agent` directly. At most one session is active at any time (invariant A1), so
9//! the map will virtually always contain 0 or 1 entries.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13use std::time::Instant;
14
15use tracing::error;
16
17use super::autonomous::{AutonomousState, SupervisorVerdict};
18
19/// Snapshot of one autonomous session suitable for cross-crate display.
20///
21/// This is a value-type copy — the registry retains the live session. Constructed by
22/// [`AutonomousRegistry::list`].
23#[derive(Debug, Clone)]
24pub struct SessionSnapshot {
25    /// Goal ID (UUID string).
26    pub goal_id: String,
27    /// First 80 characters of goal text (for display).
28    pub goal_text_short: String,
29    /// Current session state.
30    pub state: AutonomousState,
31    /// Number of turns executed so far.
32    pub turns_executed: u32,
33    /// Maximum turns for this session.
34    pub max_turns: u32,
35    /// Wall-clock time elapsed since session start.
36    pub elapsed: std::time::Duration,
37    /// Last supervisor verdict, if any.
38    pub last_verdict: Option<SupervisorVerdict>,
39}
40
41/// Shared registry of autonomous goal sessions.
42///
43/// Cloned freely — all clones share the same underlying `Mutex<HashMap>`. The agent owns one
44/// clone; the `/agents` handler owns another.
45///
46/// # Example
47///
48/// ```rust
49/// use zeph_core::goal::registry::AutonomousRegistry;
50///
51/// let reg = AutonomousRegistry::default();
52/// let snapshots = reg.list();
53/// assert!(snapshots.is_empty());
54/// ```
55#[derive(Clone, Default)]
56pub struct AutonomousRegistry {
57    inner: Arc<Mutex<HashMap<String, RegistryEntry>>>,
58}
59
60struct RegistryEntry {
61    goal_text: String,
62    state: AutonomousState,
63    turns_executed: u32,
64    max_turns: u32,
65    started_at: Instant,
66    last_verdict: Option<SupervisorVerdict>,
67}
68
69impl AutonomousRegistry {
70    /// Create an empty registry.
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Insert or update a session entry.
77    ///
78    /// All fields are taken as a flat argument list; the registry owns the produced entry.
79    /// The lock is never held across an `.await`.
80    #[allow(clippy::too_many_arguments)]
81    pub fn upsert(
82        &self,
83        goal_id: impl Into<String>,
84        goal_text: impl Into<String>,
85        state: AutonomousState,
86        turns_executed: u32,
87        max_turns: u32,
88        started_at: Instant,
89        last_verdict: Option<SupervisorVerdict>,
90    ) {
91        let goal_id = goal_id.into();
92        let entry = RegistryEntry {
93            goal_text: goal_text.into(),
94            state,
95            turns_executed,
96            max_turns,
97            started_at,
98            last_verdict,
99        };
100        let mut map = self.inner.lock().unwrap_or_else(|e| {
101            error!("AutonomousRegistry::upsert: mutex poisoned, recovering guard");
102            e.into_inner()
103        });
104        map.insert(goal_id, entry);
105    }
106
107    /// Remove a session entry.
108    pub fn remove(&self, goal_id: &str) {
109        let mut map = self.inner.lock().unwrap_or_else(|e| {
110            error!("AutonomousRegistry::remove: mutex poisoned, recovering guard");
111            e.into_inner()
112        });
113        map.remove(goal_id);
114    }
115
116    /// Return snapshots for all sessions currently in the registry.
117    ///
118    /// In practice this is 0 or 1 entries (invariant A1).
119    #[must_use]
120    pub fn list(&self) -> Vec<SessionSnapshot> {
121        let map = self.inner.lock().unwrap_or_else(|e| {
122            error!("AutonomousRegistry::list: mutex poisoned, recovering guard");
123            e.into_inner()
124        });
125        map.iter()
126            .map(|(id, e)| {
127                let short = if e.goal_text.len() > 80 {
128                    let end = e.goal_text.floor_char_boundary(80);
129                    format!("{}…", &e.goal_text[..end])
130                } else {
131                    e.goal_text.clone()
132                };
133                SessionSnapshot {
134                    goal_id: id.clone(),
135                    goal_text_short: short,
136                    state: e.state,
137                    turns_executed: e.turns_executed,
138                    max_turns: e.max_turns,
139                    elapsed: e.started_at.elapsed(),
140                    last_verdict: e.last_verdict.clone(),
141                }
142            })
143            .collect()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn empty_by_default() {
153        let reg = AutonomousRegistry::new();
154        assert!(reg.list().is_empty());
155    }
156
157    #[test]
158    fn upsert_and_list() {
159        let reg = AutonomousRegistry::new();
160        reg.upsert(
161            "id1",
162            "do something useful",
163            AutonomousState::Running,
164            3,
165            20,
166            Instant::now(),
167            None,
168        );
169        let list = reg.list();
170        assert_eq!(list.len(), 1);
171        assert_eq!(list[0].goal_id, "id1");
172        assert_eq!(list[0].state, AutonomousState::Running);
173        assert_eq!(list[0].turns_executed, 3);
174    }
175
176    #[test]
177    fn remove_clears_entry() {
178        let reg = AutonomousRegistry::new();
179        reg.upsert(
180            "id2",
181            "text",
182            AutonomousState::Running,
183            0,
184            5,
185            Instant::now(),
186            None,
187        );
188        assert_eq!(reg.list().len(), 1);
189        reg.remove("id2");
190        assert!(reg.list().is_empty());
191    }
192
193    #[test]
194    fn long_goal_text_is_truncated() {
195        let reg = AutonomousRegistry::new();
196        let long_text = "a".repeat(200);
197        reg.upsert(
198            "id3",
199            long_text,
200            AutonomousState::Running,
201            0,
202            5,
203            Instant::now(),
204            None,
205        );
206        let snap = &reg.list()[0];
207        assert!(
208            snap.goal_text_short.len() <= 84,
209            "truncation should keep display short"
210        );
211    }
212
213    #[test]
214    fn upsert_overwrites_existing_entry() {
215        let reg = AutonomousRegistry::new();
216        reg.upsert(
217            "id5",
218            "original text",
219            AutonomousState::Running,
220            1,
221            10,
222            Instant::now(),
223            None,
224        );
225        reg.upsert(
226            "id5",
227            "updated text",
228            AutonomousState::Verifying,
229            5,
230            10,
231            Instant::now(),
232            None,
233        );
234        let list = reg.list();
235        assert_eq!(list.len(), 1, "upsert should not create duplicates");
236        assert_eq!(list[0].state, AutonomousState::Verifying);
237        assert_eq!(list[0].turns_executed, 5);
238    }
239
240    #[test]
241    fn remove_nonexistent_is_noop() {
242        let reg = AutonomousRegistry::new();
243        reg.remove("does-not-exist"); // must not panic
244        assert!(reg.list().is_empty());
245    }
246
247    #[test]
248    fn clone_shares_state() {
249        let reg = AutonomousRegistry::new();
250        let reg2 = reg.clone();
251        reg.upsert(
252            "id4",
253            "shared",
254            AutonomousState::Running,
255            0,
256            5,
257            Instant::now(),
258            None,
259        );
260        assert_eq!(reg2.list().len(), 1, "clone should see the same data");
261    }
262}