Skip to main content

nexo_tool_meta/
microapp_error.rs

1//! `nexo/notify/microapp_error` wire shape.
2//!
3//! When a microapp subprocess misbehaves, the daemon's stdio
4//! supervisor emits a structured notification on the broker so
5//! operators get an actionable signal in admin-ui, Prometheus,
6//! and the audit log — instead of having to tail the
7//! microapp's stderr.
8//!
9//! Four canonical error kinds (forward-compat with `#[non_exhaustive]`):
10//!
11//! | Kind | When the daemon emits |
12//! |---|---|
13//! | `InitTimeout` | `initialize` did not return within timeout |
14//! | `HandlerPanic` | `tools/call` returned `error.data.unhandled = true` |
15//! | `Exit` | Subprocess exited with non-zero code |
16//! | `CapabilityDenied` | Microapp called an admin RPC it does not hold |
17//!
18//! The notification is `{"jsonrpc":"2.0","method":
19//! "nexo/notify/microapp_error","params":<MicroappError>}`.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23
24/// Method string for the JSON-RPC notification.
25pub const MICROAPP_ERROR_NOTIFY_METHOD: &str = "nexo/notify/microapp_error";
26
27/// Kind discriminator. `#[non_exhaustive]` so future kinds
28/// (e.g. a `RateLimitTripped` once a respawn-throttle ships)
29/// can land non-major.
30#[non_exhaustive]
31#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum MicroappErrorKind {
35    /// Microapp didn't reply to `initialize` within the
36    /// configured timeout.
37    InitTimeout,
38    /// `tools/call` returned an error with
39    /// `data.unhandled = true` — i.e. a panic / unhandled
40    /// exception inside the microapp's handler.
41    HandlerPanic,
42    /// Subprocess exited with a non-zero exit code (or
43    /// SIGSEGV / SIGTERM / etc).
44    Exit,
45    /// Microapp issued an admin-RPC call (`nexo/admin/*`)
46    /// that it does not hold the capability for.
47    CapabilityDenied,
48}
49
50impl MicroappErrorKind {
51    /// Stable wire string. Matches `serde(rename_all =
52    /// "snake_case")` so admin-ui / Prometheus labels stay
53    /// consistent.
54    pub fn as_wire_str(self) -> &'static str {
55        match self {
56            MicroappErrorKind::InitTimeout => "init_timeout",
57            MicroappErrorKind::HandlerPanic => "handler_panic",
58            MicroappErrorKind::Exit => "exit",
59            MicroappErrorKind::CapabilityDenied => "capability_denied",
60        }
61    }
62}
63
64/// Notification payload.
65#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67pub struct MicroappError {
68    /// Operator-facing extension id (`extensions.yaml.entries.<id>`).
69    pub microapp_id: String,
70    /// Discriminator for the four canonical error categories.
71    pub kind: MicroappErrorKind,
72    /// JSON-RPC `id` that triggered the error, when applicable.
73    /// `None` for `Exit` (no in-flight call) and for boot-time
74    /// failures.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub correlation_id: Option<String>,
77    /// UTC wall-clock at which the daemon observed the error.
78    pub occurred_at: DateTime<Utc>,
79    /// One-line human-readable summary surfaced in the admin-ui
80    /// hover tooltip and Prometheus annotations. Keep terse —
81    /// stack trace goes in `stack_trace`.
82    pub summary: String,
83    /// Optional multi-line stack trace / traceback. Bounded by
84    /// the daemon's stdio reader (16 KiB cap by convention) so
85    /// a runaway microapp can't blow up the broker.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub stack_trace: Option<String>,
88}
89
90impl MicroappError {
91    /// Convenience: build a notification with `Utc::now()` as
92    /// the timestamp.
93    pub fn new(
94        microapp_id: impl Into<String>,
95        kind: MicroappErrorKind,
96        summary: impl Into<String>,
97    ) -> Self {
98        Self {
99            microapp_id: microapp_id.into(),
100            kind,
101            correlation_id: None,
102            occurred_at: Utc::now(),
103            summary: summary.into(),
104            stack_trace: None,
105        }
106    }
107
108    /// Set the JSON-RPC `id` correlation. Use the `app:<uuid>`
109    /// id when reporting failures of microapp-initiated outbound
110    /// calls.
111    pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
112        self.correlation_id = Some(id.into());
113        self
114    }
115
116    /// Attach a multi-line stack trace. Daemon caps it at 16 KiB
117    /// before broadcasting on the broker.
118    pub fn with_stack_trace(mut self, trace: impl Into<String>) -> Self {
119        self.stack_trace = Some(trace.into());
120        self
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn notify_method_constant_is_stable() {
130        // Operators key admin-ui subscriptions on this string —
131        // changing it is a breaking wire change.
132        assert_eq!(MICROAPP_ERROR_NOTIFY_METHOD, "nexo/notify/microapp_error");
133    }
134
135    #[test]
136    fn kind_serialises_snake_case() {
137        let json = serde_json::to_string(&MicroappErrorKind::InitTimeout).unwrap();
138        assert_eq!(json, "\"init_timeout\"");
139        let json = serde_json::to_string(&MicroappErrorKind::HandlerPanic).unwrap();
140        assert_eq!(json, "\"handler_panic\"");
141        let json = serde_json::to_string(&MicroappErrorKind::CapabilityDenied).unwrap();
142        assert_eq!(json, "\"capability_denied\"");
143        let json = serde_json::to_string(&MicroappErrorKind::Exit).unwrap();
144        assert_eq!(json, "\"exit\"");
145    }
146
147    #[test]
148    fn kind_wire_str_matches_serde() {
149        for k in [
150            MicroappErrorKind::InitTimeout,
151            MicroappErrorKind::HandlerPanic,
152            MicroappErrorKind::Exit,
153            MicroappErrorKind::CapabilityDenied,
154        ] {
155            let serde_json = serde_json::to_string(&k).unwrap();
156            // serde_json wraps in quotes; strip them.
157            let trimmed = serde_json.trim_matches('"');
158            assert_eq!(trimmed, k.as_wire_str());
159        }
160    }
161
162    #[test]
163    fn microapp_error_round_trips_full_payload() {
164        let err = MicroappError {
165            microapp_id: "agent-creator".into(),
166            kind: MicroappErrorKind::HandlerPanic,
167            correlation_id: Some("12".into()),
168            occurred_at: chrono::DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
169                .unwrap()
170                .with_timezone(&Utc),
171            summary: "tool 'register' panicked: index out of bounds".into(),
172            stack_trace: Some("at handler.rs:42\nat dispatch.rs:118".into()),
173        };
174        let json = serde_json::to_string(&err).unwrap();
175        let back: MicroappError = serde_json::from_str(&json).unwrap();
176        assert_eq!(back, err);
177    }
178
179    #[test]
180    fn microapp_error_optional_fields_skip_when_none() {
181        let err = MicroappError::new(
182            "agent-creator",
183            MicroappErrorKind::Exit,
184            "exit code 137 (SIGKILL)",
185        );
186        let json = serde_json::to_string(&err).unwrap();
187        assert!(!json.contains("correlation_id"));
188        assert!(!json.contains("stack_trace"));
189    }
190
191    #[test]
192    fn builder_chain_sets_optional_fields() {
193        let err = MicroappError::new(
194            "x",
195            MicroappErrorKind::CapabilityDenied,
196            "needs agents_crud",
197        )
198        .with_correlation_id("app:abc-123")
199        .with_stack_trace("at admin.rs:99");
200        assert_eq!(err.correlation_id.as_deref(), Some("app:abc-123"));
201        assert_eq!(err.stack_trace.as_deref(), Some("at admin.rs:99"));
202    }
203}