Skip to main content

agentmux_srv/server/app_api/
presence.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The cloud presence publisher's status and "Publish now"
5//! (`muxbus::wan_presence`), for Settings' "Cloud presence" line.
6
7use super::*;
8
9/// Why there is no status: no publisher runs (no WAN identity), or it has
10/// not finished its first try.
11pub(crate) const NO_STATUS: &str = "Cloud presence hasn't started on this install.";
12
13pub fn register(engine: &Arc<WshRpcEngine>, _state: &AppState) {
14    engine.register_typed(COMMAND_PRESENCE_STATUS, |_req: Option<NoArgsReq>, _ctx| async move {
15        crate::muxbus::wan_presence::status().ok_or_else(|| NO_STATUS.to_string())
16    });
17    engine.register_typed(COMMAND_PRESENCE_PUBLISH_NOW, |_req: Option<NoArgsReq>, _ctx| async move {
18        Ok(PresencePublishNowResult { started: crate::muxbus::wan_presence::publish_now() })
19    });
20}
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25
26    async fn call(command: &str) -> (serde_json::Value, String) {
27        let state = crate::server::tests::test_state();
28        let (engine, mut rx) = WshRpcEngine::new();
29        register(&engine, &state);
30        engine.handle_message(RpcMessage {
31            command: command.to_string(),
32            reqid: "req-1".to_string(),
33            data: Some(json!({})),
34            ..Default::default()
35        });
36        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await.unwrap().unwrap();
37        (resp.data.unwrap_or_default(), resp.error)
38    }
39
40    #[test]
41    fn the_status_is_snake_case_and_leaves_out_what_it_doesnt_know() {
42        let status = PresenceStatusResult {
43            state: PresenceState::SignedOut,
44            since_ms: 5,
45            last_ok_ms: None,
46            last_error: None,
47            next_try_ms: None,
48            relay_version: None,
49            offset_ms: None,
50            record_version: 2,
51            note: None,
52            off_reason: None,
53        };
54        assert_eq!(
55            serde_json::to_value(&status).unwrap(),
56            json!({ "state": "signed_out", "since_ms": 5, "record_version": 2 })
57        );
58        let full = PresenceStatusResult {
59            state: PresenceState::Retrying,
60            last_ok_ms: Some(1),
61            last_error: Some("cloud unreachable".into()),
62            next_try_ms: Some(9),
63            relay_version: Some("1.1.0".into()),
64            offset_ms: Some(-720_000),
65            note: Some("Your clock differs from the cloud's by 12 min".into()),
66            ..status
67        };
68        assert_eq!(
69            serde_json::to_value(&full).unwrap(),
70            json!({
71                "state": "retrying",
72                "since_ms": 5,
73                "last_ok_ms": 1,
74                "last_error": "cloud unreachable",
75                "next_try_ms": 9,
76                "relay_version": "1.1.0",
77                "offset_ms": -720_000,
78                "record_version": 2,
79                "note": "Your clock differs from the cloud's by 12 min",
80            })
81        );
82        for (state, wire) in [
83            (PresenceState::Publishing, "publishing"),
84            (PresenceState::Unsupported, "unsupported"),
85            (PresenceState::Rejected, "rejected"),
86            (PresenceState::Off, "off"),
87            (PresenceState::SignedOff, "signed_off"),
88        ] {
89            assert_eq!(serde_json::to_value(state).unwrap(), json!(wire));
90        }
91        let off = PresenceStatusResult {
92            state: PresenceState::Off,
93            off_reason: Some(PresenceOffReason::DevBuild),
94            ..status
95        };
96        assert_eq!(
97            serde_json::to_value(&off).unwrap(),
98            json!({ "state": "off", "since_ms": 5, "record_version": 2, "off_reason": "dev_build" })
99        );
100        for (reason, wire) in [
101            (PresenceOffReason::Setting, "setting"),
102            (PresenceOffReason::Headless, "headless"),
103            (PresenceOffReason::IsolatedHome, "isolated_home"),
104            (PresenceOffReason::TestHarness, "test_harness"),
105        ] {
106            assert_eq!(serde_json::to_value(reason).unwrap(), json!(wire));
107        }
108    }
109
110    #[tokio::test]
111    async fn without_a_publisher_the_status_says_why_and_publish_now_starts_nothing() {
112        let (_, error) = call(COMMAND_PRESENCE_STATUS).await;
113        assert_eq!(error, NO_STATUS);
114        let (data, error) = call(COMMAND_PRESENCE_PUBLISH_NOW).await;
115        assert!(error.is_empty(), "{error}");
116        assert_eq!(data, json!({ "started": false }));
117    }
118}