Skip to main content

ignition_core/actions/
sessions.rs

1//! Session actions (02-03, HLTH-08): merged list + terminate — serde
2//! models OUT, no printing (ARCHITECTURE.md layering: the Phase-6 TUI
3//! rides this same layer).
4//!
5//! Stable data shape for agents: `sessions` always serializes ALL THREE
6//! family keys (`designers`, `perspective`, `vision`) — a `--type`
7//! filter leaves the excluded keys present as EMPTY arrays, and only
8//! the requested family's endpoint is CALLED (no wasted round-trips).
9
10use serde::Serialize;
11
12use crate::client::GatewayApi;
13use crate::client::query::ListQuery;
14use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
15use crate::error::CoreError;
16
17/// Which session family a filter or termination targets. Serialized
18/// kebab-case (`"designer"` / `"perspective"` / `"vision"`) — the same
19/// tokens `--type` accepts on the CLI.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum SessionType {
23    /// Designer sessions (terminate = prune).
24    Designer,
25    /// Perspective browser sessions (terminate carries the message).
26    Perspective,
27    /// Vision clients (terminate = close).
28    Vision,
29}
30
31impl SessionType {
32    /// The kebab-case token (CLI/display form).
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Designer => "designer",
36            Self::Perspective => "perspective",
37            Self::Vision => "vision",
38        }
39    }
40}
41
42impl std::fmt::Display for SessionType {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(self.as_str())
45    }
46}
47
48/// `ign sessions` output model — all three families, always present.
49#[derive(Debug, Serialize)]
50pub struct SessionsResult {
51    /// Active Designer sessions (empty when filtered out).
52    pub designers: Vec<DesignerInfo>,
53    /// Active Perspective sessions (empty when filtered out).
54    pub perspective: Vec<PerspectiveSession>,
55    /// Active Vision clients (empty when filtered out).
56    pub vision: Vec<VisionClient>,
57}
58
59/// `ign sessions terminate` output model.
60#[derive(Debug, Serialize)]
61pub struct TerminateResult {
62    /// The family that was targeted (kebab-case in JSON).
63    pub kind: SessionType,
64    /// The terminated session/client id.
65    pub id: String,
66}
67
68/// Merge the session families (or just the requested one). Filtered-out
69/// families are present-but-empty in the result and their endpoints are
70/// NEVER called.
71pub async fn sessions(
72    api: &dyn GatewayApi,
73    type_filter: Option<SessionType>,
74) -> Result<SessionsResult, CoreError> {
75    let query = ListQuery::default();
76    let (designers, perspective, vision) = match type_filter {
77        None => (
78            api.designers(&query).await?.items,
79            api.perspective_sessions(&query).await?.items,
80            api.vision_clients(&query).await?.items,
81        ),
82        Some(SessionType::Designer) => (api.designers(&query).await?.items, Vec::new(), Vec::new()),
83        Some(SessionType::Perspective) => (
84            Vec::new(),
85            api.perspective_sessions(&query).await?.items,
86            Vec::new(),
87        ),
88        Some(SessionType::Vision) => (
89            Vec::new(),
90            Vec::new(),
91            api.vision_clients(&query).await?.items,
92        ),
93    };
94    Ok(SessionsResult {
95        designers,
96        perspective,
97        vision,
98    })
99}
100
101/// Terminate one session, mapping the family to its endpoint (designer →
102/// prune, perspective → terminate with the optional message, vision →
103/// terminate). Confirmation guarding belongs to the CALLER (the CLI
104/// refuses without `--yes` before any API construction) — the action is
105/// the obedient arm.
106pub async fn terminate_session(
107    api: &dyn GatewayApi,
108    kind: SessionType,
109    id: &str,
110    message: Option<&str>,
111) -> Result<TerminateResult, CoreError> {
112    match kind {
113        SessionType::Designer => api.prune_designer(id).await?,
114        SessionType::Perspective => api.terminate_perspective_session(id, message).await?,
115        SessionType::Vision => api.terminate_vision_client(id).await?,
116    }
117    Ok(TerminateResult {
118        kind,
119        id: id.to_string(),
120    })
121}
122
123#[cfg(test)]
124mod tests {
125    use super::{SessionType, TerminateResult, sessions, terminate_session};
126    use crate::client::GatewayApi;
127    use crate::client::query::{ListEnvelope, ListMetadata};
128    use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
129    use crate::error::CoreError;
130
131    use std::sync::Mutex;
132
133    /// A recording double: counts every list call per family and every
134    /// terminate call (kind + id + message), serving one item per list.
135    #[derive(Default)]
136    struct SessionsRig {
137        list_calls: Mutex<Vec<&'static str>>,
138        terminates: Mutex<Vec<(&'static str, String, Option<String>)>>,
139    }
140
141    fn designer(id: &str) -> DesignerInfo {
142        DesignerInfo {
143            id: id.into(),
144            address: "192.168.1.50:52526".into(),
145            user: "admin".into(),
146            project: "MyProject".into(),
147            memory: serde_json::json!({"used": 1}),
148            uptime: 600000,
149            lastcomm: 1787346747022,
150            timeout: 3600000,
151            timezone: "America/New_York".into(),
152            extra: Default::default(),
153        }
154    }
155
156    fn perspective(id: &str) -> PerspectiveSession {
157        PerspectiveSession {
158            id: id.into(),
159            username: "admin".into(),
160            authorized: true,
161            project: "MyProject".into(),
162            client_address: "10.0.0.5".into(),
163            last_comm: 1787346747022,
164            active_pages: 1,
165            user_agent: "Mozilla/5.0".into(),
166            extra: Default::default(),
167        }
168    }
169
170    fn vision(id: &str) -> VisionClient {
171        VisionClient {
172            id: id.into(),
173            address: "10.0.0.9:443".into(),
174            user: "operator".into(),
175            project: "PlantFloor".into(),
176            memory: serde_json::json!({"used": 1}),
177            uptime: 120000,
178            lastcomm: 1787346747022,
179            timeout: 3600000,
180            timezone: "UTC".into(),
181            tag_count: 1523,
182            extra: Default::default(),
183        }
184    }
185
186    fn page<T>(items: Vec<T>) -> ListEnvelope<T> {
187        ListEnvelope {
188            items,
189            metadata: ListMetadata {
190                total: 1,
191                matching: 1,
192                limit: -1,
193                offset: 0,
194            },
195        }
196    }
197
198    #[async_trait::async_trait]
199    impl GatewayApi for SessionsRig {
200        async fn bundle_generate(
201            &self,
202        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
203            unreachable!("not part of this action")
204        }
205        async fn bundle_status(
206            &self,
207        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
208            unreachable!("not part of this action")
209        }
210        async fn bundle_download(
211            &self,
212            _out: &std::path::Path,
213        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
214            unreachable!("not part of this action")
215        }
216        async fn tag_provider_list(
217            &self,
218            _query: &crate::client::query::ListQuery,
219        ) -> Result<
220            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
221            CoreError,
222        > {
223            unreachable!("not part of this action")
224        }
225        async fn tag_provider_find(
226            &self,
227            _name: &str,
228        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
229            unreachable!("not part of this action")
230        }
231        async fn tag_provider_create(
232            &self,
233            _body: &[crate::client::tags::TagProviderCreate],
234        ) -> Result<(), CoreError> {
235            unreachable!("not part of this action")
236        }
237        async fn tag_provider_delete(
238            &self,
239            _name: &str,
240            _signature: &str,
241        ) -> Result<(), CoreError> {
242            unreachable!("not part of this action")
243        }
244        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
245            unreachable!("not part of this action")
246        }
247        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
248            unreachable!("not part of this action")
249        }
250        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
251            unreachable!("not part of this action")
252        }
253        async fn backup_download(
254            &self,
255            _out: &std::path::Path,
256            _backup_type: crate::client::backup::BackupType,
257        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
258            unreachable!("not part of this action")
259        }
260        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
261            unreachable!("not part of this action")
262        }
263        async fn eam_task_history(
264            &self,
265            _limit: Option<u32>,
266            _search: Option<&str>,
267        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
268        {
269            unreachable!("not part of this action")
270        }
271        async fn eam_task_definitions(
272            &self,
273        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
274        {
275            unreachable!("not part of this action")
276        }
277        async fn eam_task_find(
278            &self,
279            _name: &str,
280        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
281            unreachable!("not part of this action")
282        }
283        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
284            unreachable!("not part of this action")
285        }
286        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
287            unreachable!("not part of this action")
288        }
289        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
290            unreachable!("not part of this action")
291        }
292        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
293            unreachable!("not part of this action")
294        }
295        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
296            unreachable!("not part of this action")
297        }
298        async fn eam_tasks_scheduled(
299            &self,
300            _running: bool,
301        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
302            unreachable!("not part of this action")
303        }
304        async fn eam_task_modify(
305            &self,
306            _definition: &serde_json::Value,
307        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
308            unreachable!("not part of this action")
309        }
310        async fn eam_task_delete(
311            &self,
312            _name: &str,
313            _signature: &str,
314            _confirm: bool,
315        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
316            unreachable!("not part of this action")
317        }
318        async fn api_call(
319            &self,
320            _call: &crate::client::apicall::ApiCallRequest,
321        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
322            unreachable!("not part of this action")
323        }
324        async fn license_status(
325            &self,
326        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
327            unreachable!("not part of this action")
328        }
329        async fn redundancy_status(
330            &self,
331        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
332            unreachable!("not part of this action")
333        }
334        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
335            unreachable!("not part of this action")
336        }
337        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
338            unreachable!("not part of this action")
339        }
340        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
341            unreachable!("not part of this action")
342        }
343        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
344            unreachable!("not part of this action")
345        }
346        async fn modules(
347            &self,
348            _quarantined: bool,
349            _query: &crate::client::query::ListQuery,
350        ) -> Result<ListEnvelope<crate::client::status::ModuleInfo>, CoreError> {
351            unreachable!("not part of this action")
352        }
353        async fn metrics_current(
354            &self,
355        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
356            unreachable!("not part of this action")
357        }
358        async fn metrics_historic(
359            &self,
360        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
361            unreachable!("not part of this action")
362        }
363        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
364            unreachable!("not part of this action")
365        }
366        async fn designers(
367            &self,
368            _query: &crate::client::query::ListQuery,
369        ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
370            self.list_calls.lock().unwrap().push("designers");
371            Ok(page(vec![designer("d-1")]))
372        }
373        async fn perspective_sessions(
374            &self,
375            _query: &crate::client::query::ListQuery,
376        ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
377            self.list_calls.lock().unwrap().push("perspective");
378            Ok(page(vec![perspective("psess-1")]))
379        }
380        async fn vision_clients(
381            &self,
382            _query: &crate::client::query::ListQuery,
383        ) -> Result<ListEnvelope<VisionClient>, CoreError> {
384            self.list_calls.lock().unwrap().push("vision");
385            Ok(page(vec![vision("v-1")]))
386        }
387        async fn terminate_perspective_session(
388            &self,
389            id: &str,
390            message: Option<&str>,
391        ) -> Result<(), CoreError> {
392            self.terminates.lock().unwrap().push((
393                "perspective",
394                id.into(),
395                message.map(str::to_string),
396            ));
397            Ok(())
398        }
399        async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
400            self.terminates
401                .lock()
402                .unwrap()
403                .push(("vision", id.into(), None));
404            Ok(())
405        }
406        async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
407            self.terminates
408                .lock()
409                .unwrap()
410                .push(("designer", id.into(), None));
411            Ok(())
412        }
413        async fn database_connections(
414            &self,
415        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
416        {
417            unreachable!("not part of this action")
418        }
419        async fn opc_connections(
420            &self,
421        ) -> Result<ListEnvelope<crate::client::connections::GatewayConnection>, CoreError>
422        {
423            unreachable!("not part of this action")
424        }
425
426        async fn logs(
427            &self,
428            _filter: &crate::client::logs::LogQuery,
429        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
430            unreachable!("not part of this double's actions")
431        }
432        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
433            unreachable!("not part of this double's actions")
434        }
435        async fn loggers(
436            &self,
437            _query: &crate::client::query::ListQuery,
438        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
439            unreachable!("not part of this double's actions")
440        }
441        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
442            unreachable!("not part of this double's actions")
443        }
444        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
445            unreachable!("not part of this double's actions")
446        }
447        async fn restart(&self) -> Result<(), CoreError> {
448            unreachable!("not part of this action")
449        }
450        async fn scan_projects(&self) -> Result<(), CoreError> {
451            unreachable!("not part of this action")
452        }
453        async fn security_properties(
454            &self,
455        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
456            unreachable!("not part of this action")
457        }
458        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
459            unreachable!("not part of this action")
460        }
461        async fn webdev_route_call(
462            &self,
463            _project: &str,
464            _route: &str,
465            _body: &serde_json::Value,
466            _extra_headers: &[(&str, &str)],
467        ) -> Result<serde_json::Value, CoreError> {
468            unreachable!("not part of this action")
469        }
470        async fn webdev_route_probe(
471            &self,
472            _project: &str,
473            _route: &str,
474            _extra_headers: &[(&str, &str)],
475        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
476            unreachable!("not part of this action")
477        }
478        async fn projects(
479            &self,
480            _query: &crate::client::query::ListQuery,
481        ) -> Result<
482            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
483            CoreError,
484        > {
485            unreachable!("not part of this action")
486        }
487        async fn project_find(
488            &self,
489            _name: &str,
490        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
491            unreachable!("not part of this action")
492        }
493        async fn project_create(
494            &self,
495            _body: &crate::client::projects::ProjectCreate,
496        ) -> Result<(), CoreError> {
497            unreachable!("not part of this action")
498        }
499        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
500            unreachable!("not part of this action")
501        }
502        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
503            unreachable!("not part of this action")
504        }
505        async fn project_modify(
506            &self,
507            _name: &str,
508            _body: &crate::client::projects::ProjectModify,
509        ) -> Result<(), CoreError> {
510            unreachable!("not part of this action")
511        }
512        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
513            unreachable!("not part of this action")
514        }
515        async fn project_export_to_file(
516            &self,
517            _name: &str,
518            _out: &std::path::Path,
519        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
520            unreachable!("not part of this action")
521        }
522        async fn project_import(
523            &self,
524            _name: &str,
525            _zip: Vec<u8>,
526            _overwrite: bool,
527        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
528            unreachable!("not part of this action")
529        }
530    }
531
532    /// Unfiltered: all three families called and present. Filtered: ONLY
533    /// the requested family is called; the others stay present-but-empty
534    /// (the stable agent shape).
535    #[tokio::test]
536    async fn sessions_filter_calls_only_the_requested_family() {
537        let rig = SessionsRig::default();
538        let merged = sessions(&rig, None).await.expect("merged list");
539        assert_eq!(merged.designers.len(), 1);
540        assert_eq!(merged.perspective.len(), 1);
541        assert_eq!(merged.vision.len(), 1);
542        assert_eq!(
543            *rig.list_calls.lock().unwrap(),
544            vec!["designers", "perspective", "vision"]
545        );
546
547        let rig = SessionsRig::default();
548        let filtered = sessions(&rig, Some(SessionType::Perspective))
549            .await
550            .expect("filtered list");
551        assert!(filtered.designers.is_empty(), "excluded key stays present");
552        assert_eq!(filtered.perspective.len(), 1);
553        assert!(filtered.vision.is_empty());
554        assert_eq!(
555            *rig.list_calls.lock().unwrap(),
556            vec!["perspective"],
557            "no round-trips for excluded families"
558        );
559
560        // The JSON shape keeps all three keys (agent contract).
561        let json = serde_json::to_value(&filtered).expect("serialize");
562        let mut keys: Vec<&str> = json
563            .as_object()
564            .unwrap()
565            .keys()
566            .map(String::as_str)
567            .collect();
568        keys.sort_unstable();
569        assert_eq!(keys, ["designers", "perspective", "vision"]);
570    }
571
572    /// Termination maps kind → endpoint exactly: designer → prune,
573    /// perspective → terminate (message rides along), vision →
574    /// terminate.
575    #[tokio::test]
576    async fn terminate_maps_each_kind_to_its_endpoint() {
577        let rig = SessionsRig::default();
578        let result: TerminateResult =
579            terminate_session(&rig, SessionType::Perspective, "psess-1", Some("bye"))
580                .await
581                .expect("perspective terminates");
582        assert_eq!(result.kind, SessionType::Perspective);
583        assert_eq!(result.id, "psess-1");
584        assert_eq!(
585            serde_json::to_value(&result).unwrap()["kind"],
586            "perspective",
587            "kind serializes kebab-case"
588        );
589
590        terminate_session(&rig, SessionType::Designer, "d-1", Some("ignored"))
591            .await
592            .expect("designer prunes (message not applicable)");
593        terminate_session(&rig, SessionType::Vision, "v-1", None)
594            .await
595            .expect("vision terminates");
596        assert_eq!(
597            *rig.terminates.lock().unwrap(),
598            vec![
599                ("perspective", "psess-1".into(), Some("bye".into())),
600                ("designer", "d-1".into(), None),
601                ("vision", "v-1".into(), None),
602            ]
603        );
604    }
605}