Skip to main content

liminal_server/console/
topology.rs

1//! The topology document (SRV-011 R1), built from in-memory state only.
2
3use liminal::channel::ChannelMode;
4
5use crate::ServerError;
6use crate::server::connection::channel_registry::{ChannelOrigin, ChannelStatus};
7use crate::server::connection::services::LiminalConnectionServices;
8
9/// One channel (a line) as the console draws it.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ChannelNode {
12    /// The channel name.
13    pub name: String,
14    /// `durable` or `ephemeral`.
15    pub mode: &'static str,
16    /// `live` (admitting) or `held` (quiesced).
17    pub state: &'static str,
18    /// `config` (boot) or `runtime` (registered live).
19    pub origin: &'static str,
20    /// The quiesce cause, when held.
21    pub reason: Option<String>,
22}
23
24/// The topology document.
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct TopologySnapshot {
27    /// Every channel on the roster, sorted by name.
28    pub channels: Vec<ChannelNode>,
29    /// Every registered conversation responder subject, sorted.
30    pub services: Vec<String>,
31}
32
33/// Builds the topology from the roster, read once (pinned by
34/// `the_snapshot_reads_the_roster_once`), and the responder registry. Reads
35/// no store row.
36///
37/// # Errors
38/// Returns [`ServerError`] when the roster or responder lock is poisoned.
39pub fn snapshot(services: &LiminalConnectionServices) -> Result<TopologySnapshot, ServerError> {
40    let roster_error = |error: &dyn std::fmt::Display| ServerError::ListenerAccept {
41        message: format!("console topology: {error}"),
42    };
43    let census = services
44        .channel_census()
45        .map_err(|error| roster_error(&error))?;
46    let mut channels = Vec::with_capacity(census.len());
47    for (name, status) in census {
48        let node = match status {
49            ChannelStatus::NotRegistered => continue,
50            ChannelStatus::Active { origin, mode, .. } => ChannelNode {
51                name,
52                mode: mode_word(mode),
53                state: "live",
54                origin: origin_word(origin),
55                reason: None,
56            },
57            ChannelStatus::Quiesced {
58                reason,
59                origin,
60                mode,
61            } => ChannelNode {
62                name,
63                mode: mode_word(mode),
64                state: "held",
65                origin: origin_word(origin),
66                reason: Some(reason),
67            },
68        };
69        channels.push(node);
70    }
71    Ok(TopologySnapshot {
72        channels,
73        services: services.responder_subjects()?,
74    })
75}
76
77const fn mode_word(mode: ChannelMode) -> &'static str {
78    match mode {
79        ChannelMode::Durable => "durable",
80        ChannelMode::Ephemeral => "ephemeral",
81    }
82}
83
84const fn origin_word(origin: ChannelOrigin) -> &'static str {
85    match origin {
86        ChannelOrigin::BootConfigured => "config",
87        ChannelOrigin::RuntimeRegistered => "runtime",
88    }
89}
90
91/// Adds the proved far stations after local agent and construction projections.
92///
93/// # Errors
94/// Discovery bounds, invalid documents or a poisoned far registry.
95pub fn with_far_stations(
96    payload: &[u8],
97    far: &crate::link::federated::FarTopology,
98) -> Result<Vec<u8>, crate::link::federated::DiscoveryError> {
99    far.project(payload)
100}
101
102/// Adds construction services after the agent projection.
103///
104/// # Errors
105/// Invalid topology shape, poisoned registry or serialization failure.
106pub fn with_sites(
107    payload: &[u8],
108    lifecycle: &crate::services::lifecycle::Lifecycle,
109    device: &str,
110) -> Result<Vec<u8>, crate::services::lifecycle::site::SiteError> {
111    use crate::services::lifecycle::site::SiteError;
112    let mut document: serde_json::Value =
113        serde_json::from_slice(payload).map_err(|_| SiteError::Invalid("topology JSON"))?;
114    let mut stations = document["stations"]
115        .as_array()
116        .ok_or(SiteError::Invalid("topology stations"))?
117        .clone();
118    for station in lifecycle.site_stations(device)? {
119        let id = station["id"]
120            .as_str()
121            .ok_or(SiteError::Invalid("site station id"))?;
122        let resource = station["service"]
123            .as_str()
124            .ok_or(SiteError::Invalid("site resource id"))?;
125        let subjects = station["subjects"]
126            .as_array()
127            .ok_or(SiteError::Invalid("site subjects"))?;
128        let mut removed = Vec::new();
129        stations.retain(|old| {
130            let local = old["device"].as_str() == Some(device);
131            let worker = local
132                && old["kind"] == "worker"
133                && old["subjects"].as_array().is_some_and(|old_subjects| {
134                    old_subjects
135                        .iter()
136                        .any(|subject| subjects.contains(subject))
137                });
138            let replaced = local
139                && (old["id"].as_str() == Some(id)
140                    || (old["kind"] == "service" && old["id"].as_str() == Some(resource)));
141            if worker || replaced {
142                if let Some(name) = old["id"].as_str() {
143                    removed.push(name.to_owned());
144                }
145                false
146            } else {
147                true
148            }
149        });
150        if let Some(lines) = document["lines"].as_array_mut() {
151            for line in lines {
152                if line["kind"] == "call" && subjects.contains(&line["id"]) {
153                    let stops = line["stops"]
154                        .as_array_mut()
155                        .ok_or(SiteError::Invalid("site line stops"))?;
156                    for stop in stops.iter_mut() {
157                        if stop
158                            .as_str()
159                            .is_some_and(|name| removed.iter().any(|old| old == name))
160                        {
161                            *stop = id.into();
162                        }
163                    }
164                }
165            }
166        }
167        stations.push(station);
168    }
169    document["stations"] = stations.into();
170    liminal::channel::canonical::to_vec(document)
171        .map_err(|_| SiteError::Invalid("site topology encoding"))
172}
173
174/// Adds approved agents to the hub's whole document, preserving its revision
175///
176/// and every existing station. Presence supplies the state; the existing line
177/// stops supply the subjects the ticket actually listens to.
178///
179/// # Errors
180/// A document or agent record cannot be read or encoded.
181pub fn with_agents(
182    payload: &[u8],
183    agents: &crate::agents::runtime::Agents,
184    device: &str,
185) -> Result<Vec<u8>, crate::agents::error::AgentError> {
186    use crate::agents::error::AgentError;
187    use crate::console::records::{AgentStation, StationState};
188    let mut document: serde_json::Value =
189        serde_json::from_slice(payload).map_err(|_| AgentError::Invalid("topology JSON"))?;
190    let records = agents.registry().station_records()?;
191    for record in records {
192        let connection = agents.presence().connection(&record.name)?;
193        let id = format!("agents.{}.inbox", record.name);
194        if !record.active() {
195            document["stations"]
196                .as_array_mut()
197                .ok_or(AgentError::Invalid("topology stations"))?
198                .retain(|station| station["id"].as_str() != Some(id.as_str()));
199            continue;
200        }
201        let mut stops = connection.map_or(Ok(Vec::new()), |connection| {
202            agents.listened_subjects(connection)
203        })?;
204        if let Some(connection) = connection {
205            let connection = connection.to_string();
206            if let Some(lines) = document["lines"].as_array_mut() {
207                for line in lines {
208                    let line_name = line["id"].as_str().map(str::to_owned);
209                    if let Some(stations) = line["stops"].as_array_mut() {
210                        let listened = stations
211                            .iter()
212                            .any(|stop| stop.as_str() == Some(&connection));
213                        if listened {
214                            for stop in stations {
215                                if stop.as_str() == Some(&connection) {
216                                    *stop = id.clone().into();
217                                }
218                            }
219                            if let Some(name) = line_name {
220                                stops.push(name);
221                            }
222                        }
223                    }
224                }
225            }
226        }
227        if let Some(lines) = document["lines"].as_array_mut() {
228            for line in lines {
229                if line["id"]
230                    .as_str()
231                    .is_some_and(|subject| stops.iter().any(|own| own == subject))
232                {
233                    let stations = line["stops"]
234                        .as_array_mut()
235                        .ok_or(AgentError::Invalid("line stops"))?;
236                    if !stations
237                        .iter()
238                        .any(|station| station.as_str() == Some(id.as_str()))
239                    {
240                        stations.push(id.clone().into());
241                    }
242                }
243                if line["id"].as_str() == Some(id.as_str()) {
244                    line["kind"] = "stream".into();
245                    line["life"] = "kept".into();
246                }
247            }
248        }
249        stops.sort();
250        stops.dedup();
251        let state = agents
252            .retained()
253            .value(&format!("agents.{}.state", record.name))?;
254        let state = match state.as_ref().and_then(|state| state["state"].as_str()) {
255            Some("waiting") => StationState::Waiting,
256            Some("present") if connection.is_some() => StationState::Present,
257            _ => StationState::Gone,
258        };
259        let station = serde_json::to_value(AgentStation::new(&record, device, state, stops))
260            .map_err(|_| AgentError::Invalid("agent station encoding"))?;
261        let stations = document["stations"]
262            .as_array_mut()
263            .ok_or(AgentError::Invalid("topology stations"))?;
264        stations.retain(|station| station["id"].as_str() != Some(&id));
265        stations.push(station);
266    }
267    liminal::channel::canonical::to_vec(document)
268        .map_err(|_| AgentError::Invalid("agent topology encoding"))
269}