Skip to main content

liminal_server/agents/
presence.rs

1//! One verified agent per connection, with departure told by the transport.
2
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5
6use serde_json::json;
7
8use super::error::AgentError;
9use super::records::{AgentEvent, AgentTransition};
10use super::registry::AgentRegistry;
11use super::retained::Retained;
12
13/// Why a person's older window lost the seat to a newer one.
14const SUPERSEDED: &str = "superseded by a newer window of the same person";
15
16/// Live seats only. An inbox unsubscribe does not pretend the connection closed.
17#[derive(Debug, Default)]
18pub struct Presence {
19    connections: Mutex<BTreeMap<String, Seat>>,
20}
21
22/// The connection holding one agent's seat, and when it was admitted: what a
23/// refused second connection is told, so the holder is named.
24#[derive(Clone, Copy, Debug)]
25struct Seat {
26    connection: u64,
27    since_ms: u64,
28}
29
30/// Identity and time already proved by the subscription's admission.
31#[derive(Clone, Copy)]
32pub struct ConnectionObservation<'a> {
33    /// The agent named by the pass.
34    pub name: &'a str,
35    /// The verified actor.
36    pub holder: &'a str,
37    /// The connection process.
38    pub connection: u64,
39    /// Epoch milliseconds of admission.
40    pub at_ms: u64,
41}
42
43impl Presence {
44    /// The agent actually present on this connection, if one.
45    ///
46    /// # Errors
47    /// The presence lock is poisoned.
48    pub fn agent(&self, connection: u64) -> Result<Option<String>, AgentError> {
49        Ok(self
50            .connections
51            .lock()
52            .map_err(|_| AgentError::Store("presence lock poisoned".into()))?
53            .iter()
54            .find(|(_, own)| own.connection == connection)
55            .map(|(name, _)| name.clone()))
56    }
57    /// The first successful inbox subscription records presence once.
58    /// `attach` installs the real inbox before a presence transition is landed.
59    /// The lock covers duplicate admission, installation, and durable recording.
60    ///
61    /// # Errors
62    /// A duplicate seat, unknown agent, failed subscription, or unresolved write.
63    pub fn connect<T>(
64        &self,
65        registry: &AgentRegistry,
66        retained: &Retained,
67        observation: ConnectionObservation<'_>,
68        attach: impl FnOnce() -> Result<T, AgentError>,
69    ) -> Result<T, AgentError> {
70        let ConnectionObservation {
71            name,
72            holder,
73            connection,
74            at_ms,
75        } = observation;
76        if connection == 0 {
77            return Err(AgentError::Invalid("connection id"));
78        }
79        if holder != name {
80            return Err(AgentError::Identity {
81                holder: holder.into(),
82                agent: name.into(),
83            });
84        }
85        let mut connections = self
86            .connections
87            .lock()
88            .map_err(|_| AgentError::Store("presence lock poisoned".into()))?;
89        if let Some(&existing) = connections.get(name) {
90            if existing.connection == connection {
91                return attach();
92            }
93            // An agent holds one seat. A person opens windows: the newest one takes the seat and
94            // the older connection is recorded gone, so a second tab never refuses the person.
95            if !name.starts_with("person-") {
96                return Err(AgentError::Present {
97                    agent: name.into(),
98                    connection: existing.connection,
99                    since_ms: existing.since_ms,
100                });
101            }
102            registry.observe(
103                name,
104                AgentTransition {
105                    holder: "server".into(),
106                    at_ms,
107                    change: AgentEvent::Gone {
108                        connection: existing.connection,
109                        reason: SUPERSEDED.into(),
110                    },
111                },
112            )?;
113            connections.remove(name);
114        }
115        if connections
116            .values()
117            .any(|existing| existing.connection == connection)
118        {
119            return Err(AgentError::Transition(
120                "one connection cannot claim two agents",
121            ));
122        }
123        let record = registry.record(name)?;
124        if !record.active() {
125            return Err(AgentError::Transition("agent is retired"));
126        }
127        let subscription = attach()?;
128        registry.observe(
129            name,
130            AgentTransition {
131                holder: holder.into(),
132                at_ms,
133                change: AgentEvent::Present { connection },
134            },
135        )?;
136        connections.insert(
137            name.into(),
138            Seat {
139                connection,
140                since_ms: at_ms,
141            },
142        );
143        retained.publish(&[
144            (
145                format!("agents.{name}.presence"),
146                json!({"present":true,"at_ms":at_ms,"connection":connection}),
147            ),
148            (
149                format!("agents.{name}.state"),
150                presence_state(retained, name, false)?,
151            ),
152        ])?;
153        drop(connections);
154        Ok(subscription)
155    }
156
157    /// Publishes absent with the transport's stable reason. A stale close
158    /// cannot remove another connection, and repeated closes land nothing.
159    ///
160    /// # Errors
161    /// A malformed reason, unavailable store, or failed delivery.
162    pub fn close(
163        &self,
164        registry: &AgentRegistry,
165        retained: &Retained,
166        connection: u64,
167        reason: &str,
168        at_ms: u64,
169    ) -> Result<(), AgentError> {
170        let mut connections = self
171            .connections
172            .lock()
173            .map_err(|_| AgentError::Store("presence lock poisoned".into()))?;
174        let name = connections
175            .iter()
176            .find(|(_, own)| own.connection == connection)
177            .map(|(name, _)| name.clone());
178        let Some(name) = name else {
179            return Ok(());
180        };
181        registry.observe(
182            &name,
183            AgentTransition {
184                holder: "server".into(),
185                at_ms,
186                change: AgentEvent::Gone {
187                    connection,
188                    reason: reason.into(),
189                },
190            },
191        )?;
192        connections.remove(&name);
193        let result = retained.publish(&[
194            (
195                format!("agents.{name}.presence"),
196                json!({"present":false,"at_ms":at_ms,"reason":reason}),
197            ),
198            (
199                format!("agents.{name}.state"),
200                presence_state(retained, &name, true)?,
201            ),
202        ]);
203        drop(connections);
204        result
205    }
206
207    /// The observed seat, without reading the durable history.
208    ///
209    /// # Errors
210    /// The presence lock is unavailable.
211    pub fn connection(&self, name: &str) -> Result<Option<u64>, AgentError> {
212        Ok(self
213            .connections
214            .lock()
215            .map_err(|_| AgentError::Store("presence lock poisoned".into()))?
216            .get(name)
217            .map(|seat| seat.connection))
218    }
219}
220
221fn presence_state(
222    retained: &Retained,
223    name: &str,
224    gone: bool,
225) -> Result<serde_json::Value, AgentError> {
226    let mut state = retained
227        .value(&format!("agents.{name}.state"))?
228        .unwrap_or_else(|| json!({}));
229    if state["state"] != "waiting" {
230        state["state"] = json!(if gone { "gone" } else { "present" });
231    }
232    Ok(state)
233}