Skip to main content

daemon8_types/
lib.rs

1// SPDX-License-Identifier: LicenseRef-FCL-1.0-ALv2
2// Copyright (c) 2026 Havy.tech, LLC
3
4use std::borrow::Borrow;
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rmcp::schemars;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub const SYSTEM_TAG: &str = "_system";
16pub const OBSERVATION_SEARCH_TEXT_LIMIT_BYTES: usize = 16 * 1024;
17
18macro_rules! arc_str_newtype {
19    ($name:ident) => {
20        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21        #[serde(transparent)]
22        pub struct $name(Arc<str>);
23
24        impl $name {
25            pub fn as_str(&self) -> &str {
26                &self.0
27            }
28        }
29
30        impl fmt::Display for $name {
31            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32                f.write_str(&self.0)
33            }
34        }
35
36        impl From<&str> for $name {
37            fn from(s: &str) -> Self {
38                Self(Arc::from(s))
39            }
40        }
41
42        impl From<String> for $name {
43            fn from(s: String) -> Self {
44                Self(Arc::from(s.as_str()))
45            }
46        }
47
48        impl From<Arc<str>> for $name {
49            fn from(s: Arc<str>) -> Self {
50                Self(s)
51            }
52        }
53
54        impl AsRef<str> for $name {
55            fn as_ref(&self) -> &str {
56                &self.0
57            }
58        }
59
60        impl Borrow<str> for $name {
61            fn borrow(&self) -> &str {
62                &self.0
63            }
64        }
65
66        impl PartialEq<str> for $name {
67            fn eq(&self, other: &str) -> bool {
68                self.as_str() == other
69            }
70        }
71
72        impl PartialEq<&str> for $name {
73            fn eq(&self, other: &&str) -> bool {
74                self.as_str() == *other
75            }
76        }
77
78        impl FromStr for $name {
79            type Err = std::convert::Infallible;
80            fn from_str(s: &str) -> Result<Self, Self::Err> {
81                Ok(Self::from(s))
82            }
83        }
84
85        impl JsonSchema for $name {
86            fn schema_name() -> std::borrow::Cow<'static, str> {
87                stringify!($name).into()
88            }
89            fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
90                String::json_schema(generator)
91            }
92        }
93    };
94}
95
96arc_str_newtype!(TabId);
97arc_str_newtype!(SessionId);
98arc_str_newtype!(DeviceSerial);
99arc_str_newtype!(AppName);
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(rename_all = "snake_case")]
103pub enum DebugAction {
104    EvalJs,
105    Screenshot,
106    InjectCss,
107    RevertCss,
108    ListTabs,
109    GetPerfMetrics,
110    GetDom,
111    SetViewport,
112    ClearViewport,
113    NetworkConditions,
114    Navigate,
115    StorageClear,
116    StorageInspect,
117    StorageSet,
118    ElementAtPoint,
119    NewTab,
120    CloseTab,
121}
122
123#[derive(
124    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
125)]
126#[serde(rename_all = "snake_case")]
127pub enum Severity {
128    Trace,
129    Debug,
130    Info,
131    Warn,
132    Error,
133}
134
135impl Severity {
136    pub fn level(self) -> u8 {
137        match self {
138            Self::Trace => 0,
139            Self::Debug => 1,
140            Self::Info => 2,
141            Self::Warn => 3,
142            Self::Error => 4,
143        }
144    }
145}
146
147impl fmt::Display for Severity {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        let s = match self {
150            Self::Trace => "trace",
151            Self::Debug => "debug",
152            Self::Info => "info",
153            Self::Warn => "warn",
154            Self::Error => "error",
155        };
156        f.write_str(s)
157    }
158}
159
160impl std::str::FromStr for Severity {
161    type Err = String;
162
163    fn from_str(s: &str) -> Result<Self, Self::Err> {
164        match s.to_ascii_lowercase().as_str() {
165            "trace" => Ok(Self::Trace),
166            "debug" => Ok(Self::Debug),
167            "info" => Ok(Self::Info),
168            "warn" => Ok(Self::Warn),
169            "error" => Ok(Self::Error),
170            other => Err(format!("unknown severity: {other}")),
171        }
172    }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case", tag = "type")]
177pub enum Origin {
178    Application {
179        name: AppName,
180    },
181    Browser {
182        tab_id: TabId,
183        url: String,
184    },
185    Device {
186        serial: DeviceSerial,
187        platform: DevicePlatform,
188    },
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum DevicePlatform {
194    #[default]
195    Android,
196    Vega,
197}
198
199/// Enum discriminant without payload — use for filtering/matching without carrying variant data.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
201#[serde(rename_all = "snake_case")]
202pub enum ObservationKindTag {
203    Log,
204    Query,
205    HttpExchange,
206    Exception,
207    StateSnapshot,
208    Metric,
209    Custom,
210    JsException,
211    Lifecycle,
212    ToolCall,
213}
214
215impl ObservationKindTag {
216    pub fn is_dedup_exempt(self) -> bool {
217        matches!(self, Self::ToolCall | Self::Metric | Self::Lifecycle)
218    }
219}
220
221pub trait SourceActivator: Send + Sync {
222    fn touch_matching(&self, filter: &Filter);
223}
224
225impl fmt::Display for ObservationKindTag {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        let s = match self {
228            Self::Log => "log",
229            Self::Query => "query",
230            Self::HttpExchange => "http_exchange",
231            Self::Exception => "exception",
232            Self::StateSnapshot => "state_snapshot",
233            Self::Metric => "metric",
234            Self::Custom => "custom",
235            Self::JsException => "js_exception",
236            Self::Lifecycle => "lifecycle",
237            Self::ToolCall => "tool_call",
238        };
239        f.write_str(s)
240    }
241}
242
243impl std::str::FromStr for ObservationKindTag {
244    type Err = String;
245
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        match s.to_ascii_lowercase().as_str() {
248            "log" => Ok(Self::Log),
249            "query" => Ok(Self::Query),
250            "http_exchange" => Ok(Self::HttpExchange),
251            "exception" => Ok(Self::Exception),
252            "state_snapshot" => Ok(Self::StateSnapshot),
253            "metric" => Ok(Self::Metric),
254            "custom" => Ok(Self::Custom),
255            "js_exception" => Ok(Self::JsException),
256            "lifecycle" => Ok(Self::Lifecycle),
257            "tool_call" => Ok(Self::ToolCall),
258            other => Err(format!("unknown observation kind: {other}")),
259        }
260    }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case", tag = "type")]
265pub enum ObservationKind {
266    Log,
267    Query {
268        sql: String,
269        duration_ms: f64,
270    },
271    HttpExchange {
272        method: String,
273        url: String,
274        status: Option<u16>,
275        duration_ms: Option<f64>,
276    },
277    Exception {
278        message: String,
279        trace: Option<String>,
280    },
281    StateSnapshot {
282        label: String,
283    },
284    Metric {
285        name: String,
286        value: f64,
287    },
288    Custom {
289        channel: String,
290    },
291    JsException {
292        message: String,
293        line: Option<u32>,
294        column: Option<u32>,
295    },
296    Lifecycle {
297        event_name: String,
298        frame_id: String,
299    },
300    ToolCall {
301        tool: String,
302        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
303        input: serde_json::Value,
304        #[serde(default, skip_serializing_if = "Option::is_none")]
305        output: Option<serde_json::Value>,
306        #[serde(default, skip_serializing_if = "Option::is_none")]
307        exit_code: Option<i32>,
308        #[serde(default, skip_serializing_if = "Option::is_none")]
309        duration_ms: Option<f64>,
310    },
311}
312
313impl ObservationKind {
314    pub fn tag(&self) -> ObservationKindTag {
315        match self {
316            Self::Log => ObservationKindTag::Log,
317            Self::Query { .. } => ObservationKindTag::Query,
318            Self::HttpExchange { .. } => ObservationKindTag::HttpExchange,
319            Self::Exception { .. } => ObservationKindTag::Exception,
320            Self::StateSnapshot { .. } => ObservationKindTag::StateSnapshot,
321            Self::Metric { .. } => ObservationKindTag::Metric,
322            Self::Custom { .. } => ObservationKindTag::Custom,
323            Self::JsException { .. } => ObservationKindTag::JsException,
324            Self::Lifecycle { .. } => ObservationKindTag::Lifecycle,
325            Self::ToolCall { .. } => ObservationKindTag::ToolCall,
326        }
327    }
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct SourceLocation {
332    pub file: String,
333    pub line: u32,
334    pub function: Option<String>,
335}
336
337/// Monotonic sequence number. "I've seen everything up to this ID."
338#[derive(
339    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
340)]
341pub struct Checkpoint(pub u64);
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct Observation {
345    pub id: u64,
346    pub origin: Origin,
347    pub kind: ObservationKind,
348    pub data: serde_json::Value,
349    pub severity: Severity,
350    pub source_location: Option<SourceLocation>,
351    pub timestamp_ns: u64,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub correlation_id: Option<Arc<str>>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub parent_id: Option<u64>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub tags: Option<Vec<String>>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub session_id: Option<Arc<str>>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub node_id: Option<Arc<str>>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub debug_session_id: Option<Arc<str>>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub checkpoint_id: Option<Arc<str>>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub error_hash: Option<Arc<str>>,
368}
369
370impl Observation {
371    /// Build a new observation. `id` starts at 0 (the store assigns the real
372    /// monotonic sequence on insert) and `timestamp_ns` is captured from the
373    /// system clock at call time.
374    pub fn new(
375        origin: Origin,
376        kind: ObservationKind,
377        data: serde_json::Value,
378        severity: Severity,
379        source_location: Option<SourceLocation>,
380    ) -> Self {
381        let timestamp_ns = SystemTime::now()
382            .duration_since(UNIX_EPOCH)
383            .expect("system clock before UNIX epoch")
384            .as_nanos() as u64;
385
386        Self {
387            id: 0,
388            origin,
389            kind,
390            data,
391            severity,
392            source_location,
393            timestamp_ns,
394            correlation_id: None,
395            parent_id: None,
396            tags: None,
397            session_id: None,
398            node_id: None,
399            debug_session_id: None,
400            checkpoint_id: None,
401            error_hash: None,
402        }
403    }
404}
405
406pub fn observation_origin_fields(origin: &Origin) -> (&'static str, String) {
407    match origin {
408        Origin::Application { name } => ("application", format!("app:{name}")),
409        Origin::Browser { tab_id, .. } => ("browser", format!("browser:{tab_id}")),
410        Origin::Device { serial, .. } => ("device", format!("device:{serial}")),
411    }
412}
413
414pub fn observation_search_text(obs: &Observation) -> String {
415    let (_, origin_key) = observation_origin_fields(&obs.origin);
416    let mut text = String::new();
417
418    push_search_part(&mut text, obs.severity.to_string());
419    push_search_part(&mut text, obs.kind.tag().to_string());
420    push_search_part(&mut text, origin_key);
421    push_search_part(&mut text, origin_search_text(&obs.origin));
422    if let Ok(kind) = serde_json::to_string(&obs.kind) {
423        push_search_part(&mut text, kind);
424    }
425
426    if let Some(ref tags) = obs.tags {
427        for tag in tags {
428            push_search_part(&mut text, tag);
429        }
430    }
431
432    if let Some(ref location) = obs.source_location {
433        push_search_part(&mut text, &location.file);
434        push_search_part(&mut text, location.line.to_string());
435        if let Some(ref function) = location.function {
436            push_search_part(&mut text, function);
437        }
438    }
439
440    if let Some(ref correlation_id) = obs.correlation_id {
441        push_search_part(&mut text, correlation_id);
442    }
443    if let Some(parent_id) = obs.parent_id {
444        push_search_part(&mut text, parent_id.to_string());
445    }
446    if let Some(ref session_id) = obs.session_id {
447        push_search_part(&mut text, session_id);
448    }
449    if let Some(ref node_id) = obs.node_id {
450        push_search_part(&mut text, node_id);
451    }
452    if let Some(ref debug_session_id) = obs.debug_session_id {
453        push_search_part(&mut text, debug_session_id);
454    }
455    if let Some(ref checkpoint_id) = obs.checkpoint_id {
456        push_search_part(&mut text, checkpoint_id);
457    }
458    if let Some(ref error_hash) = obs.error_hash {
459        push_search_part(&mut text, error_hash);
460    }
461
462    push_search_part(&mut text, obs.data.to_string());
463    truncate_observation_search_text(text)
464}
465
466fn origin_search_text(origin: &Origin) -> String {
467    match origin {
468        Origin::Application { name } => format!("application {name}"),
469        Origin::Browser { tab_id, url } => format!("browser {tab_id} {url}"),
470        Origin::Device { serial, platform } => format!("device {serial} {platform:?}"),
471    }
472}
473
474fn push_search_part(text: &mut String, part: impl AsRef<str>) {
475    let part = part.as_ref();
476    if part.is_empty() {
477        return;
478    }
479    if !text.is_empty() {
480        text.push(' ');
481    }
482    text.push_str(part);
483}
484
485fn truncate_observation_search_text(mut text: String) -> String {
486    if text.len() <= OBSERVATION_SEARCH_TEXT_LIMIT_BYTES {
487        return text;
488    }
489
490    let mut end = OBSERVATION_SEARCH_TEXT_LIMIT_BYTES;
491    while !text.is_char_boundary(end) {
492        end -= 1;
493    }
494    text.truncate(end);
495    text
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
499#[serde(rename_all = "snake_case")]
500pub enum OriginPattern {
501    AnyApplication,
502    ApplicationNamed(AppName),
503    AnyBrowser,
504    BrowserTab(TabId),
505    AnyDevice,
506    DeviceSerial(DeviceSerial),
507}
508
509impl OriginPattern {
510    pub fn parse(s: &str) -> Self {
511        match s.split_once(':') {
512            Some(("app", "*")) | None if s == "app" => Self::AnyApplication,
513            Some(("app", name)) => Self::ApplicationNamed(name.into()),
514            Some(("browser", "*")) | None if s == "browser" => Self::AnyBrowser,
515            Some(("browser", tab_id)) => Self::BrowserTab(tab_id.into()),
516            Some(("device", "*")) | None if s == "device" => Self::AnyDevice,
517            Some(("device", serial)) => Self::DeviceSerial(serial.into()),
518            _ => Self::ApplicationNamed(s.into()),
519        }
520    }
521}
522
523#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
524pub struct Filter {
525    pub kinds: Option<Vec<ObservationKindTag>>,
526    pub severity_min: Option<Severity>,
527    pub origins: Option<Vec<OriginPattern>>,
528    pub text_match: Option<String>,
529    pub since: Option<Checkpoint>,
530    pub limit: Option<usize>,
531    pub correlation_id: Option<String>,
532    pub tags: Option<Vec<String>>,
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub include_system: Option<bool>,
535}
536
537impl Filter {
538    pub fn matches(&self, obs: &Observation) -> bool {
539        if !self.include_system.unwrap_or(false)
540            && let Some(ref tags) = obs.tags
541            && tags.iter().any(|t| t == SYSTEM_TAG)
542        {
543            return false;
544        }
545
546        if let Some(ref cp) = self.since
547            && obs.id <= cp.0
548        {
549            return false;
550        }
551
552        if let Some(min) = self.severity_min
553            && obs.severity.level() < min.level()
554        {
555            return false;
556        }
557
558        if let Some(ref kinds) = self.kinds
559            && !kinds.is_empty()
560            && !kinds.contains(&obs.kind.tag())
561        {
562            return false;
563        }
564
565        if let Some(ref origins) = self.origins
566            && !origins.is_empty()
567            && !origins.iter().any(|p| p.matches(&obs.origin))
568        {
569            return false;
570        }
571
572        if let Some(ref text) = self.text_match {
573            let text = text.trim();
574            if !text.is_empty()
575                && !observation_search_text(obs)
576                    .to_ascii_lowercase()
577                    .contains(&text.to_ascii_lowercase())
578            {
579                return false;
580            }
581        }
582
583        if let Some(ref cid) = self.correlation_id {
584            match &obs.correlation_id {
585                Some(obs_cid) if obs_cid.as_ref() == cid.as_str() => {}
586                _ => return false,
587            }
588        }
589
590        if let Some(ref required_tags) = self.tags
591            && !required_tags.is_empty()
592        {
593            match &obs.tags {
594                Some(obs_tags) => {
595                    if !required_tags.iter().all(|t| obs_tags.contains(t)) {
596                        return false;
597                    }
598                }
599                None => return false,
600            }
601        }
602
603        true
604    }
605
606    pub fn parse_severity(raw: &str) -> Option<Severity> {
607        raw.trim().parse::<Severity>().ok()
608    }
609
610    pub fn parse_kinds(raw: &str) -> Vec<ObservationKindTag> {
611        raw.split(',')
612            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
613            .collect()
614    }
615
616    pub fn parse_origins(raw: &str) -> Vec<OriginPattern> {
617        raw.split(',')
618            .map(|s| OriginPattern::parse(s.trim()))
619            .collect()
620    }
621
622    pub fn parse_tags(raw: &str) -> Vec<String> {
623        raw.split(',')
624            .map(|s| s.trim().to_string())
625            .filter(|s| !s.is_empty())
626            .collect()
627    }
628
629    pub fn kinds_from_vec(v: Vec<String>) -> Vec<ObservationKindTag> {
630        v.into_iter()
631            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
632            .collect()
633    }
634
635    pub fn origins_from_vec(v: Vec<String>) -> Vec<OriginPattern> {
636        v.into_iter()
637            .map(|s| OriginPattern::parse(s.trim()))
638            .collect()
639    }
640}
641
642impl OriginPattern {
643    pub fn matches(&self, origin: &Origin) -> bool {
644        match (self, origin) {
645            (Self::AnyApplication, Origin::Application { .. }) => true,
646            (Self::ApplicationNamed(name), Origin::Application { name: n }) => n == name,
647            (Self::AnyBrowser, Origin::Browser { .. }) => true,
648            (Self::BrowserTab(tab), Origin::Browser { tab_id, .. }) => tab_id == tab,
649            (Self::AnyDevice, Origin::Device { .. }) => true,
650            (Self::DeviceSerial(serial), Origin::Device { serial: s, .. }) => s == serial,
651            _ => false,
652        }
653    }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
657#[serde(rename_all = "snake_case")]
658pub enum MemoryKind {
659    Pattern,
660    Decision,
661    ErrorSignature,
662    SessionSummary,
663    UserFlagged,
664}
665
666impl fmt::Display for MemoryKind {
667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668        let s = match self {
669            Self::Pattern => "pattern",
670            Self::Decision => "decision",
671            Self::ErrorSignature => "error_signature",
672            Self::SessionSummary => "session_summary",
673            Self::UserFlagged => "user_flagged",
674        };
675        f.write_str(s)
676    }
677}
678
679impl std::str::FromStr for MemoryKind {
680    type Err = String;
681
682    fn from_str(s: &str) -> Result<Self, Self::Err> {
683        match s.to_ascii_lowercase().as_str() {
684            "pattern" => Ok(Self::Pattern),
685            "decision" => Ok(Self::Decision),
686            "error_signature" => Ok(Self::ErrorSignature),
687            "session_summary" => Ok(Self::SessionSummary),
688            "user_flagged" => Ok(Self::UserFlagged),
689            other => Err(format!("unknown memory kind: {other}")),
690        }
691    }
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
695#[serde(rename_all = "snake_case")]
696pub enum DebugSessionStatus {
697    Active,
698    Completed,
699    Abandoned,
700}
701
702impl fmt::Display for DebugSessionStatus {
703    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704        let s = match self {
705            Self::Active => "active",
706            Self::Completed => "completed",
707            Self::Abandoned => "abandoned",
708        };
709        f.write_str(s)
710    }
711}
712
713impl std::str::FromStr for DebugSessionStatus {
714    type Err = String;
715
716    fn from_str(s: &str) -> Result<Self, Self::Err> {
717        match s.to_ascii_lowercase().as_str() {
718            "active" => Ok(Self::Active),
719            "completed" => Ok(Self::Completed),
720            "abandoned" => Ok(Self::Abandoned),
721            other => Err(format!("unknown debug session status: {other}")),
722        }
723    }
724}
725
726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
727#[serde(rename_all = "snake_case")]
728pub enum DebugSessionOutcome {
729    Resolved,
730    Abandoned,
731    InProgress,
732}
733
734impl fmt::Display for DebugSessionOutcome {
735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
736        let s = match self {
737            Self::Resolved => "resolved",
738            Self::Abandoned => "abandoned",
739            Self::InProgress => "in_progress",
740        };
741        f.write_str(s)
742    }
743}
744
745impl std::str::FromStr for DebugSessionOutcome {
746    type Err = String;
747
748    fn from_str(s: &str) -> Result<Self, Self::Err> {
749        match s.to_ascii_lowercase().as_str() {
750            "resolved" => Ok(Self::Resolved),
751            "abandoned" => Ok(Self::Abandoned),
752            "in_progress" => Ok(Self::InProgress),
753            other => Err(format!("unknown debug session outcome: {other}")),
754        }
755    }
756}
757
758// ── Librarian catalog enums ──────────────────────────────────────────
759
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
761#[serde(rename_all = "snake_case")]
762pub enum LibrarianNodeKind {
763    Doc,
764    SourceTemplate,
765    Fix,
766    Project,
767}
768
769impl fmt::Display for LibrarianNodeKind {
770    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
771        let s = match self {
772            Self::Doc => "doc",
773            Self::SourceTemplate => "source_template",
774            Self::Fix => "fix",
775            Self::Project => "project",
776        };
777        f.write_str(s)
778    }
779}
780
781impl std::str::FromStr for LibrarianNodeKind {
782    type Err = String;
783
784    fn from_str(s: &str) -> Result<Self, Self::Err> {
785        match s.to_ascii_lowercase().as_str() {
786            "doc" => Ok(Self::Doc),
787            "source_template" => Ok(Self::SourceTemplate),
788            "fix" => Ok(Self::Fix),
789            "project" => Ok(Self::Project),
790            other => Err(format!("unknown librarian node kind: {other}")),
791        }
792    }
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
796#[serde(rename_all = "snake_case")]
797pub enum LibrarianEdgeKind {
798    HasSource,
799    DocumentedBy,
800    Fixes,
801    Supersedes,
802    ChildOf,
803}
804
805impl fmt::Display for LibrarianEdgeKind {
806    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
807        let s = match self {
808            Self::HasSource => "has_source",
809            Self::DocumentedBy => "documented_by",
810            Self::Fixes => "fixes",
811            Self::Supersedes => "supersedes",
812            Self::ChildOf => "child_of",
813        };
814        f.write_str(s)
815    }
816}
817
818impl std::str::FromStr for LibrarianEdgeKind {
819    type Err = String;
820
821    fn from_str(s: &str) -> Result<Self, Self::Err> {
822        match s.to_ascii_lowercase().as_str() {
823            "has_source" => Ok(Self::HasSource),
824            "documented_by" => Ok(Self::DocumentedBy),
825            "fixes" => Ok(Self::Fixes),
826            "supersedes" => Ok(Self::Supersedes),
827            "child_of" => Ok(Self::ChildOf),
828            other => Err(format!("unknown librarian edge kind: {other}")),
829        }
830    }
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
834#[serde(rename_all = "snake_case")]
835pub enum LocatorKind {
836    File,
837    Url,
838    Vault,
839}
840
841impl fmt::Display for LocatorKind {
842    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
843        let s = match self {
844            Self::File => "file",
845            Self::Url => "url",
846            Self::Vault => "vault",
847        };
848        f.write_str(s)
849    }
850}
851
852impl std::str::FromStr for LocatorKind {
853    type Err = String;
854
855    fn from_str(s: &str) -> Result<Self, Self::Err> {
856        match s.to_ascii_lowercase().as_str() {
857            "file" => Ok(Self::File),
858            "url" => Ok(Self::Url),
859            "vault" => Ok(Self::Vault),
860            other => Err(format!("unknown locator kind: {other}")),
861        }
862    }
863}
864
865#[derive(Debug, Clone, Serialize, Deserialize)]
866pub struct SliceSummary {
867    pub total: usize,
868    pub counts_by_kind: HashMap<String, usize>,
869    pub counts_by_severity: HashMap<String, usize>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct StateSlice {
874    pub observations: Vec<Observation>,
875    pub checkpoint: Checkpoint,
876    pub summary: SliceSummary,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
880#[serde(rename_all = "snake_case")]
881pub enum HealthStatus {
882    Ok,
883    ErrorsDetected,
884    NoSources,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum ConnectionKind {
890    Application,
891    Browser,
892    Device,
893}
894
895#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct ConnectionInfo {
897    pub id: String,
898    pub kind: ConnectionKind,
899    pub name: String,
900    pub observation_count: u64,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize)]
904pub struct RuntimeSummary {
905    pub observation_count: u64,
906    pub error_count_last_60s: u64,
907    pub active_channels: Vec<String>,
908    pub connections: Vec<ConnectionInfo>,
909    pub health: HealthStatus,
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use serde_json::{Value, json};
916
917    fn obs(origin: Origin, kind: ObservationKind) -> Observation {
918        Observation {
919            id: 0,
920            origin,
921            kind,
922            data: json!({"note": "roundtrip fixture"}),
923            severity: Severity::Info,
924            source_location: None,
925            timestamp_ns: 0,
926            correlation_id: None,
927            parent_id: None,
928            tags: None,
929            session_id: None,
930            node_id: None,
931            debug_session_id: None,
932            checkpoint_id: None,
933            error_hash: None,
934        }
935    }
936
937    fn roundtrip_observation(o: &Observation) {
938        let text = serde_json::to_string(o).expect("serialize");
939        let back: Observation = serde_json::from_str(&text).expect("deserialize");
940        let text_again = serde_json::to_string(&back).expect("reserialize");
941        assert_eq!(
942            text, text_again,
943            "observation should roundtrip identically; first={text} second={text_again}"
944        );
945    }
946
947    #[test]
948    fn origin_application_roundtrip() {
949        roundtrip_observation(&obs(
950            Origin::Application {
951                name: AppName::from("test-app"),
952            },
953            ObservationKind::Log,
954        ));
955    }
956
957    #[test]
958    fn origin_browser_roundtrip() {
959        roundtrip_observation(&obs(
960            Origin::Browser {
961                tab_id: TabId::from("tab-abc-123"),
962                url: "https://example.com/page".into(),
963            },
964            ObservationKind::Log,
965        ));
966    }
967
968    #[test]
969    fn origin_device_roundtrip() {
970        roundtrip_observation(&obs(
971            Origin::Device {
972                serial: DeviceSerial::from("emulator-5554"),
973                platform: DevicePlatform::Android,
974            },
975            ObservationKind::Log,
976        ));
977    }
978
979    #[test]
980    fn observation_kind_variants_all_roundtrip() {
981        let cases = vec![
982            ObservationKind::Log,
983            ObservationKind::Query {
984                sql: "SELECT 1".into(),
985                duration_ms: 3.5,
986            },
987            ObservationKind::HttpExchange {
988                method: "GET".into(),
989                url: "/api/users".into(),
990                status: Some(200),
991                duration_ms: Some(42.0),
992            },
993            ObservationKind::Exception {
994                message: "boom".into(),
995                trace: Some("stack".into()),
996            },
997            ObservationKind::StateSnapshot {
998                label: "before-migration".into(),
999            },
1000            ObservationKind::Metric {
1001                name: "cpu".into(),
1002                value: 0.75,
1003            },
1004            ObservationKind::Custom {
1005                channel: "events".into(),
1006            },
1007            ObservationKind::JsException {
1008                message: "undefined".into(),
1009                line: Some(12),
1010                column: Some(5),
1011            },
1012            ObservationKind::Lifecycle {
1013                event_name: "ready".into(),
1014                frame_id: "frame-1".into(),
1015            },
1016            ObservationKind::ToolCall {
1017                tool: "Bash".into(),
1018                input: json!({"command": "ls"}),
1019                output: Some(json!({"stdout": "Cargo.toml\n"})),
1020                exit_code: Some(0),
1021                duration_ms: Some(12.5),
1022            },
1023        ];
1024
1025        for kind in cases {
1026            roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
1027        }
1028    }
1029
1030    #[test]
1031    fn newtypes_serialize_transparent_as_strings() {
1032        let origin = Origin::Browser {
1033            tab_id: TabId::from("abc"),
1034            url: "https://x".into(),
1035        };
1036        let v: Value = serde_json::to_value(&origin).unwrap();
1037        assert_eq!(
1038            v["tab_id"],
1039            json!("abc"),
1040            "TabId must serialize as a bare string, got {v:#?}"
1041        );
1042
1043        let origin = Origin::Device {
1044            serial: DeviceSerial::from("S123"),
1045            platform: DevicePlatform::Vega,
1046        };
1047        let v: Value = serde_json::to_value(&origin).unwrap();
1048        assert_eq!(v["serial"], json!("S123"));
1049
1050        let origin = Origin::Application {
1051            name: AppName::from("my-app"),
1052        };
1053        let v: Value = serde_json::to_value(&origin).unwrap();
1054        assert_eq!(v["name"], json!("my-app"));
1055    }
1056
1057    #[test]
1058    fn newtypes_deserialize_from_bare_strings() {
1059        let v = json!({
1060            "type": "browser",
1061            "tab_id": "tab-42",
1062            "url": "https://example.com"
1063        });
1064        let origin: Origin = serde_json::from_value(v).unwrap();
1065        match origin {
1066            Origin::Browser { tab_id, url } => {
1067                assert_eq!(tab_id.as_str(), "tab-42");
1068                assert_eq!(url, "https://example.com");
1069            }
1070            _ => panic!("expected Browser origin"),
1071        }
1072    }
1073
1074    #[test]
1075    fn origin_pattern_roundtrip_all_variants() {
1076        let patterns = vec![
1077            OriginPattern::AnyApplication,
1078            OriginPattern::ApplicationNamed(AppName::from("svc")),
1079            OriginPattern::AnyBrowser,
1080            OriginPattern::BrowserTab(TabId::from("tab-9")),
1081            OriginPattern::AnyDevice,
1082            OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
1083        ];
1084        for p in patterns {
1085            let text = serde_json::to_string(&p).unwrap();
1086            let back: OriginPattern = serde_json::from_str(&text).unwrap();
1087            let text_again = serde_json::to_string(&back).unwrap();
1088            assert_eq!(text, text_again);
1089        }
1090    }
1091
1092    #[test]
1093    fn filter_matches_honors_newtype_identity() {
1094        let matching = obs(
1095            Origin::Browser {
1096                tab_id: TabId::from("tab-1"),
1097                url: "".into(),
1098            },
1099            ObservationKind::Log,
1100        );
1101        let other = obs(
1102            Origin::Browser {
1103                tab_id: TabId::from("tab-2"),
1104                url: "".into(),
1105            },
1106            ObservationKind::Log,
1107        );
1108        let filter = Filter {
1109            origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
1110            ..Filter::default()
1111        };
1112        assert!(filter.matches(&matching));
1113        assert!(!filter.matches(&other));
1114    }
1115
1116    #[test]
1117    fn observation_search_text_includes_filter_metadata() {
1118        let mut observation = obs(
1119            Origin::Device {
1120                serial: DeviceSerial::from("ABC123"),
1121                platform: DevicePlatform::Vega,
1122            },
1123            ObservationKind::Exception {
1124                message: "surface failed".into(),
1125                trace: Some("stack".into()),
1126            },
1127        );
1128        observation.data = json!({"message": "HDMI overlay timeout"});
1129        observation.source_location = Some(SourceLocation {
1130            file: "src/device.rs".into(),
1131            line: 77,
1132            function: Some("render_overlay".into()),
1133        });
1134        observation.tags = Some(vec!["project:daemon8".into(), "domain:device".into()]);
1135        observation.correlation_id = Some(Arc::from("corr-1"));
1136        observation.session_id = Some(Arc::from("session-1"));
1137        observation.node_id = Some(Arc::from("node-1"));
1138
1139        let search_text = observation_search_text(&observation);
1140
1141        assert!(search_text.contains("device:ABC123"));
1142        assert!(search_text.contains("project:daemon8"));
1143        assert!(search_text.contains("corr-1"));
1144        assert!(search_text.contains("src/device.rs"));
1145        assert!(search_text.contains("surface failed"));
1146        assert!(search_text.len() <= OBSERVATION_SEARCH_TEXT_LIMIT_BYTES);
1147    }
1148
1149    #[test]
1150    fn filter_text_match_searches_materialized_metadata() {
1151        let mut matching = obs(
1152            Origin::Application {
1153                name: AppName::from("daemon8-test"),
1154            },
1155            ObservationKind::Log,
1156        );
1157        matching.tags = Some(vec!["domain:device".into()]);
1158        matching.correlation_id = Some(Arc::from("corr-1"));
1159
1160        let other = obs(
1161            Origin::Application {
1162                name: AppName::from("daemon8-test"),
1163            },
1164            ObservationKind::Log,
1165        );
1166
1167        let tag_filter = Filter {
1168            text_match: Some("domain:device".into()),
1169            ..Filter::default()
1170        };
1171        assert!(tag_filter.matches(&matching));
1172        assert!(!tag_filter.matches(&other));
1173
1174        let partial_filter = Filter {
1175            text_match: Some("dev".into()),
1176            ..Filter::default()
1177        };
1178        assert!(partial_filter.matches(&matching));
1179        assert!(!partial_filter.matches(&other));
1180
1181        let origin_filter = Filter {
1182            text_match: Some("app:daemon8-test".into()),
1183            ..Filter::default()
1184        };
1185        assert!(origin_filter.matches(&matching));
1186        assert!(origin_filter.matches(&other));
1187
1188        let blank_filter = Filter {
1189            text_match: Some("   ".into()),
1190            ..Filter::default()
1191        };
1192        assert!(blank_filter.matches(&other));
1193    }
1194
1195    #[test]
1196    fn tab_id_equality_regardless_of_construction_path() {
1197        let from_str = TabId::from("abc");
1198        let from_string = TabId::from(String::from("abc"));
1199        let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
1200        assert_eq!(from_str, from_string);
1201        assert_eq!(from_string, from_arc);
1202        assert_eq!(from_str, "abc");
1203    }
1204
1205    #[test]
1206    fn system_tag_excluded_by_default() {
1207        let mut system_obs = obs(
1208            Origin::Application {
1209                name: AppName::from("hook"),
1210            },
1211            ObservationKind::Log,
1212        );
1213        system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
1214
1215        let normal_obs = obs(
1216            Origin::Application {
1217                name: AppName::from("app"),
1218            },
1219            ObservationKind::Log,
1220        );
1221
1222        let default_filter = Filter::default();
1223        assert!(!default_filter.matches(&system_obs));
1224        assert!(default_filter.matches(&normal_obs));
1225
1226        let include_filter = Filter {
1227            include_system: Some(true),
1228            ..Filter::default()
1229        };
1230        assert!(include_filter.matches(&system_obs));
1231        assert!(include_filter.matches(&normal_obs));
1232    }
1233
1234    #[test]
1235    fn debug_action_roundtrip_snake_case() {
1236        let cases = vec![
1237            (DebugAction::EvalJs, "eval_js"),
1238            (DebugAction::GetPerfMetrics, "get_perf_metrics"),
1239            (DebugAction::SetViewport, "set_viewport"),
1240            (DebugAction::StorageInspect, "storage_inspect"),
1241            (DebugAction::ElementAtPoint, "element_at_point"),
1242        ];
1243        for (variant, wire) in cases {
1244            let text = serde_json::to_string(&variant).unwrap();
1245            assert_eq!(text, format!("\"{wire}\""));
1246            let back: DebugAction = serde_json::from_str(&text).unwrap();
1247            assert_eq!(back, variant);
1248        }
1249    }
1250
1251    #[test]
1252    fn librarian_node_kind_roundtrip() {
1253        for (variant, wire) in [
1254            (LibrarianNodeKind::Doc, "doc"),
1255            (LibrarianNodeKind::SourceTemplate, "source_template"),
1256            (LibrarianNodeKind::Fix, "fix"),
1257            (LibrarianNodeKind::Project, "project"),
1258        ] {
1259            let json = serde_json::to_string(&variant).unwrap();
1260            assert_eq!(json, format!("\"{wire}\""));
1261            let back: LibrarianNodeKind = serde_json::from_str(&json).unwrap();
1262            assert_eq!(back, variant);
1263            assert_eq!(variant.to_string(), wire);
1264            assert_eq!(wire.parse::<LibrarianNodeKind>().unwrap(), variant);
1265        }
1266    }
1267
1268    #[test]
1269    fn librarian_edge_kind_roundtrip() {
1270        for (variant, wire) in [
1271            (LibrarianEdgeKind::HasSource, "has_source"),
1272            (LibrarianEdgeKind::DocumentedBy, "documented_by"),
1273            (LibrarianEdgeKind::Fixes, "fixes"),
1274            (LibrarianEdgeKind::Supersedes, "supersedes"),
1275            (LibrarianEdgeKind::ChildOf, "child_of"),
1276        ] {
1277            let json = serde_json::to_string(&variant).unwrap();
1278            assert_eq!(json, format!("\"{wire}\""));
1279            let back: LibrarianEdgeKind = serde_json::from_str(&json).unwrap();
1280            assert_eq!(back, variant);
1281            assert_eq!(variant.to_string(), wire);
1282            assert_eq!(wire.parse::<LibrarianEdgeKind>().unwrap(), variant);
1283        }
1284    }
1285
1286    #[test]
1287    fn locator_kind_roundtrip() {
1288        for (variant, wire) in [
1289            (LocatorKind::File, "file"),
1290            (LocatorKind::Url, "url"),
1291            (LocatorKind::Vault, "vault"),
1292        ] {
1293            let json = serde_json::to_string(&variant).unwrap();
1294            assert_eq!(json, format!("\"{wire}\""));
1295            let back: LocatorKind = serde_json::from_str(&json).unwrap();
1296            assert_eq!(back, variant);
1297            assert_eq!(variant.to_string(), wire);
1298            assert_eq!(wire.parse::<LocatorKind>().unwrap(), variant);
1299        }
1300    }
1301
1302    #[test]
1303    fn dedup_exempt_kinds() {
1304        assert!(ObservationKindTag::ToolCall.is_dedup_exempt());
1305        assert!(ObservationKindTag::Metric.is_dedup_exempt());
1306        assert!(ObservationKindTag::Lifecycle.is_dedup_exempt());
1307
1308        assert!(!ObservationKindTag::Log.is_dedup_exempt());
1309        assert!(!ObservationKindTag::Query.is_dedup_exempt());
1310        assert!(!ObservationKindTag::HttpExchange.is_dedup_exempt());
1311        assert!(!ObservationKindTag::Exception.is_dedup_exempt());
1312        assert!(!ObservationKindTag::JsException.is_dedup_exempt());
1313        assert!(!ObservationKindTag::StateSnapshot.is_dedup_exempt());
1314        assert!(!ObservationKindTag::Custom.is_dedup_exempt());
1315    }
1316}