1use std::sync::{Arc, OnceLock};
4
5use liminal::channel::{ChannelSupervisor, InboxInstall};
6use liminal::durability::{DurableStore, bridge::block_on};
7use liminal::protocol::SchemaId;
8use serde_json::json;
9
10use super::{
11 binding,
12 config::AgentsConfig,
13 error::AgentError,
14 presence::Presence,
15 records::{AgentEvent, AgentTransition},
16 registry::{AgentRegistry, AgentSeats, REGISTRY_STREAM},
17 retained::Retained,
18};
19use crate::auth_lys::{HotAction, session::LysSession};
20use crate::config::types::ServerConfig;
21use crate::console::wall::WallBase;
22use crate::server::connection::services::{ConnectionSubscription, LiminalConnectionServices};
23use crate::services::lifecycle::Placer;
24
25#[derive(Debug)]
27pub struct Agents {
28 registry: Arc<AgentRegistry>,
29 retained: Arc<Retained>,
30 presence: Presence,
31 bounds: AgentsConfig,
32 network: String,
33 wall: WallBase,
34 listens: OnceLock<Arc<super::listens::Listens>>,
35}
36
37pub struct AgentSubscription<'a> {
39 pub services: &'a LiminalConnectionServices,
41 pub connection: u64,
43 pub session: Option<&'a dyn LysSession>,
45 pub subject: &'a str,
47 pub schemas: &'a [SchemaId],
49 pub install: Option<InboxInstall>,
51 pub id: u64,
53}
54
55impl Agents {
56 pub fn bind_console(
61 &self,
62 console: &Arc<crate::console::ConsoleHub>,
63 ) -> Result<(), AgentError> {
64 self.listens
65 .set(super::listens::Listens::new(self.bounds, console))
66 .map_err(|_| AgentError::Invalid("agent console already bound"))?;
67 self.retained.bind_console(console)?;
68 Ok(())
69 }
70
71 pub fn listened_subjects(&self, connection: u64) -> Result<Vec<String>, AgentError> {
76 self.listens
77 .get()
78 .map_or(Ok(Vec::new()), |listens| listens.subjects(connection))
79 }
80
81 pub fn track_listen(
86 &self,
87 connection: u64,
88 id: u64,
89 subject: &str,
90 resource: Box<dyn crate::server::connection::services::SubscriptionResource>,
91 ) -> Result<Box<dyn crate::server::connection::services::SubscriptionResource>, AgentError>
92 {
93 if self.presence.agent(connection)?.is_none() {
94 return Ok(resource);
95 }
96 self.listens
97 .get()
98 .ok_or(AgentError::Invalid("agent console is not bound"))?
99 .attach(connection, id, subject, resource)
100 }
101 pub fn execute_approved(
107 &self,
108 draft: &lys_pass::drafts::ApprovedDraft,
109 ) -> Result<lys_pass::drafts::Execution, AgentError> {
110 let receipt = self.registry.execute_approved_at(draft, self.now()?)?;
111 if draft.target.action == "create_agent" {
112 self.created(&draft.target.id)?;
113 }
114 Ok(receipt)
115 }
116 pub fn open(
122 store: Arc<dyn DurableStore>,
123 bounds: AgentsConfig,
124 network: String,
125 supervisor: ChannelSupervisor,
126 device: &str,
127 placer: Option<Arc<dyn Placer>>,
128 seats: Option<Arc<dyn AgentSeats>>,
129 ) -> Result<Arc<Self>, AgentError> {
130 let registry = AgentRegistry::open(store, bounds, network.clone(), placer, seats)?;
131 let retained = Retained::new(bounds, supervisor, device);
132 let wall = WallBase::now().map_err(|_| AgentError::Invalid("agent wall clock"))?;
133 let now = wall
134 .unix_ms_at(std::time::Instant::now())
135 .map_err(|_| AgentError::Invalid("agent wall clock"))?;
136 for record in registry.records()? {
137 if let Some(connection) = record.connection() {
138 registry.observe(
139 &record.name,
140 AgentTransition {
141 holder: "server".into(),
142 at_ms: now,
143 change: AgentEvent::Gone {
144 connection,
145 reason: "server_restart".into(),
146 },
147 },
148 )?;
149 }
150 let current = registry.record(&record.name)?;
151 let last_gone =
152 current
153 .transitions
154 .iter()
155 .rev()
156 .find_map(|transition| match &transition.change {
157 AgentEvent::Gone { reason, .. } => {
158 Some((transition.at_ms, reason.as_str()))
159 }
160 _ => None,
161 });
162 let (at_ms, reason) = last_gone.unwrap_or((current.created_at_ms, "created"));
163 retained.publish(&[
164 (
165 format!("agents.{}.presence", record.name),
166 json!({"present":false,"at_ms":at_ms,"reason":reason}),
167 ),
168 (
169 format!("agents.{}.state", record.name),
170 json!({"state":"gone"}),
171 ),
172 ])?;
173 }
174 Ok(Arc::new(Self {
175 registry,
176 retained,
177 presence: Presence::default(),
178 bounds,
179 network,
180 wall,
181 listens: OnceLock::new(),
182 }))
183 }
184
185 #[must_use]
187 pub const fn registry(&self) -> &Arc<AgentRegistry> {
188 &self.registry
189 }
190 #[must_use]
192 pub const fn retained(&self) -> &Arc<Retained> {
193 &self.retained
194 }
195 #[must_use]
197 pub const fn bounds(&self) -> &AgentsConfig {
198 &self.bounds
199 }
200 #[must_use]
202 pub fn network(&self) -> &str {
203 &self.network
204 }
205 #[must_use]
207 pub const fn presence(&self) -> &Presence {
208 &self.presence
209 }
210
211 pub fn created(&self, name: &str) -> Result<(), AgentError> {
217 let record = self.registry.record(name)?;
218 if self.presence.connection(name)?.is_none() {
219 self.retained.publish(&[
220 (
221 format!("agents.{name}.presence"),
222 json!({"present":false,"at_ms":record.created_at_ms,"reason":"created"}),
223 ),
224 (format!("agents.{name}.state"), json!({"state":"gone"})),
225 ])?;
226 }
227 Ok(())
228 }
229
230 pub fn subscribe(
238 &self,
239 request: AgentSubscription<'_>,
240 ordinary: impl FnOnce(Option<InboxInstall>) -> Result<ConnectionSubscription, AgentError>,
241 ) -> Result<ConnectionSubscription, AgentError> {
242 let AgentSubscription {
243 services,
244 connection,
245 session,
246 subject,
247 schemas,
248 install,
249 id,
250 } = request;
251 let now = self.now()?;
252 binding::decide(session, &self.network, HotAction::Subscribe, subject, now)?;
253 let (name, leaf) = binding::subject(subject).ok_or(AgentError::Invalid("agent subject"))?;
254 if matches!(leaf, "presence" | "state") {
255 self.registry.record(name)?;
256 return self.retained.subscribe(id, subject, schemas, install);
257 }
258 if !self.registry.record(name)?.active() {
259 return Err(AgentError::Transition("agent is retired"));
260 }
261 if leaf != "inbox" {
262 return ordinary(install);
263 }
264 let session = session.ok_or(AgentError::Invalid("agent pass"))?;
265 let subscription = self.presence.connect(
266 &self.registry,
267 &self.retained,
268 super::presence::ConnectionObservation {
269 name,
270 holder: session.holder(),
271 connection,
272 at_ms: now,
273 },
274 || ordinary(install),
275 )?;
276 services
277 .console()
278 .emit(crate::console::TopologyChange::Service {
279 subject: subject.into(),
280 present: true,
281 })
282 .map_err(|_| AgentError::Delivery("agent topology change failed"))?;
283 Ok(subscription)
284 }
285
286 pub fn close(&self, connection: u64, reason: &str) -> Result<(), AgentError> {
291 self.bounds.field(reason, "transport reason")?;
292 if let Some(listens) = self.listens.get() {
293 listens.close(connection)?;
294 }
295 self.presence.close(
296 &self.registry,
297 &self.retained,
298 connection,
299 reason,
300 self.now()?,
301 )
302 }
303
304 pub(crate) fn now(&self) -> Result<u64, AgentError> {
305 self.wall
306 .unix_ms_at(std::time::Instant::now())
307 .map_err(|_| AgentError::Invalid("agent wall clock"))
308 }
309}
310
311pub fn configured(
317 config: &ServerConfig,
318 store: Arc<dyn DurableStore>,
319 supervisor: ChannelSupervisor,
320 device: &str,
321 runtime: Option<tokio::runtime::Handle>,
322) -> Result<Option<Arc<Agents>>, crate::ServerError> {
323 let lys = config.auth.as_ref().and_then(|auth| auth.lys.as_ref());
324 let bounds = lys.and_then(|lys| lys.agents);
325 let Some(bounds) = bounds else {
326 let head = block_on(store.read_stream_next_seq(REGISTRY_STREAM))
327 .map_err(|error| AgentError::Store(error.to_string()))?
328 .map_err(|error| AgentError::Store(error.to_string()))?;
329 if head.is_some() {
330 return Err(crate::ServerError::ConfigValidation {
331 message: "[auth.lys.agents] is required when a liminal.agent is placed".into(),
332 });
333 }
334 return Ok(None);
335 };
336 let lys = lys.ok_or(AgentError::Invalid("Lys configuration"))?;
337 let (placer, seats) = super::effects::configured(lys, runtime)?;
338 Ok(Some(Agents::open(
339 store,
340 bounds,
341 lys.network_id.clone(),
342 supervisor,
343 device,
344 placer,
345 seats,
346 )?))
347}
348
349pub fn connection_fate_closed(
352 services: &dyn crate::server::connection::services::ConnectionServices,
353 connection: u64,
354 class: crate::server::participant::ConnectionFateClass,
355) {
356 use crate::server::participant::ConnectionFateClass;
357 let reason = match class {
358 ConnectionFateClass::CleanDisconnect => "client_close",
359 ConnectionFateClass::ServerShutdown => "server_shutdown",
360 ConnectionFateClass::ConnectionLost => "transport_drop",
361 ConnectionFateClass::ProtocolError => "protocol_error",
362 };
363 connection_closed(services, connection, reason);
364}
365
366pub fn connection_closed(
371 services: &dyn crate::server::connection::services::ConnectionServices,
372 connection: u64,
373 reason: &str,
374) {
375 if let Some(agents) = services.agents() {
376 if let Err(error) = agents.close(connection, reason) {
377 tracing::error!(connection, %error, "agent_presence_departure_failed");
378 }
379 }
380}