1use 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#[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#[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 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#[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}