Skip to main content

mj_client/
web.rs

1//! Web-viewer data shared by Mjolnir's daemon and control surfaces.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8/// The conversation shape the phone reads. The chat layer projects its
9/// entries into this; the browser API owns the wire form.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
11pub struct BrowserTranscript {
12    pub latest_seq: u64,
13    /// Opaque Rich-presentation topology key. A browser sends the key it last
14    /// rendered with its next delta request; a mismatch means an append-only
15    /// feed may contain rows the current presentation has removed.
16    pub presentation_key: String,
17    /// Cursor boundary below which a client must replace its feed. Usually
18    /// this is the oldest retained entry, but presentation coalescing may
19    /// advance it so an append-only client drops a marker hidden by a newer
20    /// entry.
21    pub window_start_seq: u64,
22    pub reset: bool,
23    pub entries: Vec<BrowserTranscriptEntry>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct BrowserTranscriptEntry {
28    /// Submission identity for replacing a client-local pending row.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub command_id: Option<String>,
31    /// Stable identity for multiple messages delivered at the same relay ordinal.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub stable_id: Option<String>,
34    pub id: u64,
35    pub updated_seq: u64,
36    pub role: &'static str,
37    pub label: String,
38    pub recorded_at_ms: Option<i64>,
39    pub lines: Vec<String>,
40    /// The glyph the terminal draws for this role, so both surfaces read alike
41    /// without the browser keeping a second copy of the mapping. Taken from
42    /// the same `entry_visual` the terminal renders from.
43    pub glyph: &'static str,
44    /// The semantic colour name, not a colour. The stylesheet decides what
45    /// `agent` or `failed` looks like; this says which one applies.
46    pub tone: &'static str,
47    /// A tool call's state, for a tool entry. `None` for every other role.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub tool_status: Option<&'static str>,
50    /// The changed files a tool reported, as data rather than as extra lines
51    /// appended to `lines`. The terminal formats these for a terminal; a
52    /// browser re-parsing that formatting is how the phone came to render
53    /// every diffstat as one unsplit path.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub diffstats: Vec<BrowserDiffStat>,
56}
57
58impl BrowserTranscriptEntry {
59    pub fn identity(&self) -> String {
60        self.stable_id
61            .clone()
62            .unwrap_or_else(|| format!("ordinal:{}", self.id))
63    }
64}
65
66/// One file a tool changed, and by how much.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct BrowserDiffStat {
69    pub path: String,
70    pub insertions: u32,
71    pub deletions: u32,
72}
73
74#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub enum WebViewerAccess {
76    Starting,
77    Ready {
78        viewer_url: String,
79        viewer_code: String,
80        qr_login_url: Option<String>,
81        fallback_reason: Option<String>,
82        /// Lowercase hex SHA-256 of the DER certificate the viewer serves when
83        /// that certificate is one the operator configured (often self-signed).
84        /// Local clients trust exactly this certificate instead of a CA chain.
85        /// `None` over plain HTTP and for publicly trusted Tailscale
86        /// certificates, which renew in place.
87        #[serde(default)]
88        certificate_sha256: Option<String>,
89    },
90    Failed {
91        address: SocketAddr,
92        message: String,
93        port_conflict: bool,
94    },
95    Unavailable(String),
96}
97
98impl std::fmt::Debug for WebViewerAccess {
99    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::Starting => formatter.write_str("Starting"),
102            Self::Ready {
103                viewer_url,
104                fallback_reason,
105                ..
106            } => formatter
107                .debug_struct("Ready")
108                .field("viewer_url", viewer_url)
109                .field("credentials", &"[redacted]")
110                .field("fallback_reason", fallback_reason)
111                .finish(),
112            Self::Failed {
113                address,
114                message,
115                port_conflict,
116            } => formatter
117                .debug_struct("Failed")
118                .field("address", address)
119                .field("message", message)
120                .field("port_conflict", port_conflict)
121                .finish(),
122            Self::Unavailable(message) => {
123                formatter.debug_tuple("Unavailable").field(message).finish()
124            }
125        }
126    }
127}
128
129/// Identity shown before an explicit stop request and checked again before signalling.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct WebListenerProcess {
132    pub pid: u32,
133    pub name: String,
134    pub executable: PathBuf,
135    pub started_at: u64,
136    pub stop_disabled_reason: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub enum WebViewerRecovery {
141    Retry,
142    AnotherPort,
143    StopAndRetry(WebListenerProcess),
144}