Skip to main content

lean_ctx_protocol/hive/
status.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `lean-ctx hive status --json`: the one shape the local cockpit reads.
4//!
5//! It is built from local state only (no network) and prints `available:
6//! true` whenever the engine has the Hive connector, connected or not.
7//! The six fields of 1.0 are always printed; 1.2 adds `drafts`, `policy`,
8//! `filter_packs` and `policy_refusal` (readers treat them as "not
9//! reported" when an older engine leaves them out).
10
11use serde::{Deserialize, Serialize};
12
13use super::HIVE_DEFAULT_URL;
14use crate::UtcTimestamp;
15
16/// Instance shown in the cockpit.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct HiveStatusInstanceV1 {
20    pub id: String,
21    pub label: String,
22}
23
24/// Organization shown in the cockpit.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct HiveStatusOrgV1 {
28    pub name: String,
29}
30
31/// Session drafts not yet in Hive (1.2).
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct HiveStatusDraftsV1 {
35    /// Queued in the outbox, sent with the next sync.
36    pub queued: u32,
37    /// Refused by Hive for good; kept on this machine.
38    pub refused: u32,
39}
40
41/// An active filter pack (1.2). Bundles verify as a whole, so every listed
42/// pack is active; a refused bundle leaves the previous list in place.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct HiveStatusFilterPackV1 {
46    pub pack_id: String,
47    pub version: u64,
48}
49
50/// What a bus control asks of an agent (1.5).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum HiveBusControlActionV1 {
54    Hold,
55    Stop,
56    Veto,
57    #[serde(other)]
58    Unknown,
59}
60
61/// A bus control in force on this machine (1.5).
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct HiveStatusBusControlV1 {
65    /// The agent it targets; `null`: every agent of this instance.
66    pub target_agent: Option<String>,
67    pub action: HiveBusControlActionV1,
68    /// What a veto names (a lease or handoff reference).
69    pub target_ref: Option<String>,
70    pub reason: String,
71    pub issued_by: String,
72    pub since: UtcTimestamp,
73}
74
75/// Why recorded bus controls do not apply right now (1.5).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum HiveBusControlsInactiveV1 {
79    NotEnrolled,
80    LeaseLapsed,
81    #[serde(other)]
82    Unknown,
83}
84
85/// Output of `lean-ctx hive status --json`. `null` fields are printed.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct HiveCliStatusV1 {
89    /// The engine build has the Hive connector.
90    pub available: bool,
91    /// Enrolled and the lease is not lapsed.
92    pub connected: bool,
93    pub instance: Option<HiveStatusInstanceV1>,
94    pub org: Option<HiveStatusOrgV1>,
95    /// Last successful exchange with Hive.
96    pub last_seen: Option<UtcTimestamp>,
97    /// Hive web origin (the configured `[hive] url`, default hosted).
98    pub url: String,
99    /// 1.2.
100    #[serde(default)]
101    pub drafts: HiveStatusDraftsV1,
102    /// 1.2.
103    #[serde(default)]
104    pub filter_packs: Vec<HiveStatusFilterPackV1>,
105    /// 1.2: why the newest policy bundle was refused (the last verified one stays).
106    #[serde(default)]
107    pub policy_refusal: Option<String>,
108    /// 1.3: origin of the Hive web app ("Open Hive"): the one enrollment
109    /// named, else `url`.
110    #[serde(default)]
111    pub web_url: Option<String>,
112    /// 1.5: bus controls in force. Left out when empty, so readers of
113    /// earlier shapes see unchanged output.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub bus_controls: Vec<HiveStatusBusControlV1>,
116    /// 1.5: why recorded controls do not apply; left out when they do or
117    /// none are recorded.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub bus_controls_inactive: Option<HiveBusControlsInactiveV1>,
120    /// Engine metrics v2 (telemetry SPEC §4); left out when not connected.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub metrics_v2: Option<HiveStatusMetricsV2>,
123    /// 1.6: the project here was resolved to a vault its link file or its
124    /// identity no longer backs. Left out when they agree, so readers of
125    /// earlier shapes see unchanged output.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub vault_mismatch: Option<HiveStatusVaultMismatchV1>,
128    /// 1.7: this machine carries a managed configuration
129    /// (`leanctx.managed-config/v1`). Left out when it carries none, so
130    /// readers of earlier shapes see unchanged output.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub managed: Option<HiveStatusManagedV1>,
133}
134
135/// `managed` of `hive status --json` (1.7).
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct HiveStatusManagedV1 {
139    pub carrier: super::managed::ManagedCarrierKindV1,
140    /// Where the carrier lives (a path, or `HKLM\…`).
141    pub location: String,
142    /// The members in force, in schema order; empty when `error` is set.
143    pub members: Vec<HiveStatusManagedMemberV1>,
144    /// `managed_digest` of the members; `null` when `error` is set.
145    pub digest: Option<String>,
146    /// Why the carrier was refused: the engine fails closed (Hive calls
147    /// and, with nothing to go by, `hive.*` settings refuse).
148    pub error: Option<String>,
149}
150
151/// One managed member (`hive.url`, …) and its value as text.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct HiveStatusManagedMemberV1 {
155    pub member: String,
156    pub value: String,
157}
158
159/// `vault_mismatch` of `hive status --json` (1.6). That resolution is not
160/// used: the next connect or sync resolves the project again.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct HiveStatusVaultMismatchV1 {
164    /// The checkout root on this machine.
165    pub project_root: String,
166    /// `ns/name` this machine resolved it to.
167    pub resolved: String,
168    /// `ns/name` its link file names; `null` when the identity changed.
169    pub linked: Option<String>,
170    /// `link` or `identity`.
171    pub reason: String,
172}
173
174/// `metrics_v2` of `hive status --json` (telemetry SPEC §4).
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct HiveStatusMetricsV2 {
178    /// The workspace's sharing state as Hive last answered it (or the plan
179    /// default before the first answer).
180    pub sharing: super::metrics_v2::SharingStateV2,
181    pub last_sent_at: Option<String>,
182    pub buffered_buckets: u64,
183    pub dropped_buckets: u64,
184    /// `false` while engine metrics are not released: nothing is shared,
185    /// whatever `sharing` says. Older engines omit it.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub released: Option<bool>,
188}
189
190impl HiveCliStatusV1 {
191    /// Status of an engine that was never connected.
192    pub fn not_connected(url: Option<&str>) -> Self {
193        Self {
194            available: true,
195            connected: false,
196            instance: None,
197            org: None,
198            last_seen: None,
199            url: url.unwrap_or(HIVE_DEFAULT_URL).to_string(),
200            drafts: HiveStatusDraftsV1::default(),
201            filter_packs: Vec::new(),
202            policy_refusal: None,
203            web_url: Some(url.unwrap_or(HIVE_DEFAULT_URL).to_string()),
204            bus_controls: Vec::new(),
205            bus_controls_inactive: None,
206            metrics_v2: None,
207            vault_mismatch: None,
208            managed: None,
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn not_connected_prints_nulls() {
219        assert_eq!(
220            serde_json::to_string(&HiveCliStatusV1::not_connected(None)).unwrap(),
221            r#"{"available":true,"connected":false,"instance":null,"org":null,"last_seen":null,"url":"https://hive.leanctx.com","drafts":{"queued":0,"refused":0},"filter_packs":[],"policy_refusal":null,"web_url":"https://hive.leanctx.com"}"#
222        );
223    }
224
225    #[test]
226    fn bus_controls_appear_only_when_present() {
227        let mut status = HiveCliStatusV1::not_connected(None);
228        let quiet = serde_json::to_string(&status).unwrap();
229        assert!(!quiet.contains("bus_controls"));
230        status.bus_controls_inactive = Some(HiveBusControlsInactiveV1::LeaseLapsed);
231        assert!(
232            serde_json::to_string(&status)
233                .unwrap()
234                .contains(r#""bus_controls_inactive":"lease_lapsed""#)
235        );
236    }
237
238    #[test]
239    fn a_1_0_status_still_reads() {
240        let old = r#"{"available":true,"connected":false,"instance":null,"org":null,"last_seen":null,"url":"https://hive.leanctx.com"}"#;
241        let status: HiveCliStatusV1 = serde_json::from_str(old).unwrap();
242        assert_eq!(
243            status,
244            HiveCliStatusV1 {
245                web_url: None,
246                ..HiveCliStatusV1::not_connected(None)
247            }
248        );
249    }
250}