1use serde::{Deserialize, Serialize};
10
11pub const MINIMUM_SCHEDULE_MS: u64 = 10;
12pub const MAXIMUM_SCHEDULE_MS: u64 = 24 * 60 * 60 * 1_000;
13pub const MAXIMUM_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1_000;
14pub const MAXIMUM_PATTERN_BYTES: usize = 256;
15pub const MAXIMUM_MONITOR_ID_BYTES: usize = 128;
16pub const MAXIMUM_AUTHORITY_TEXT_BYTES: usize = 4_096;
17pub const MAXIMUM_COMMAND_BYTES: usize = 64 * 1_024;
18const PATTERN_WORDS: usize = (MAXIMUM_PATTERN_BYTES + 1).div_ceil(64);
19
20#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum MonitorSignal {
23 Hangup,
24 Interrupt,
25 Quit,
26 Terminate,
27 Kill,
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(tag = "kind", rename_all = "snake_case")]
32pub enum MonitorCondition {
33 ProcessExit,
34 ExitCode { exit_code: i32 },
35 Signal { signal: MonitorSignal },
36 OutputContains { pattern: String },
37 OutputMatches { pattern: String },
38 OutputQuiet { duration_ms: u64 },
39 ScreenMatches { pattern: String },
40 TcpReady { host: String, port: u16 },
41 HttpReady { pattern: String },
42 PathExists { path: String },
43 PathChanged { path: String },
44 PathSize { path: String, minimum_bytes: u64 },
45 CustomProbe { command: String, cwd: String },
46}
47
48impl MonitorCondition {
49 pub fn requires_polling(&self) -> bool {
50 matches!(
51 self,
52 Self::TcpReady { .. }
53 | Self::HttpReady { .. }
54 | Self::PathExists { .. }
55 | Self::PathChanged { .. }
56 | Self::PathSize { .. }
57 | Self::CustomProbe { .. }
58 )
59 }
60
61 fn validate(&self) -> Result<(), MonitorError> {
62 match self {
63 Self::ProcessExit | Self::ExitCode { .. } | Self::Signal { .. } => Ok(()),
64 Self::OutputContains { pattern }
65 | Self::OutputMatches { pattern }
66 | Self::ScreenMatches { pattern } => valid_text(
67 pattern,
68 MAXIMUM_PATTERN_BYTES,
69 MonitorError::InvalidCondition,
70 ),
71 Self::OutputQuiet { duration_ms } => {
72 validate_schedule(*duration_ms).map_err(|_| MonitorError::InvalidCondition)
73 }
74 Self::TcpReady { host, port } => {
75 valid_text(
76 host,
77 MAXIMUM_AUTHORITY_TEXT_BYTES,
78 MonitorError::InvalidCondition,
79 )?;
80 if *port == 0 {
81 return Err(MonitorError::InvalidCondition);
82 }
83 Ok(())
84 }
85 Self::HttpReady { pattern } => valid_text(
86 pattern,
87 MAXIMUM_AUTHORITY_TEXT_BYTES,
88 MonitorError::InvalidCondition,
89 ),
90 Self::PathExists { path }
91 | Self::PathChanged { path }
92 | Self::PathSize { path, .. } => valid_text(
93 path,
94 MAXIMUM_AUTHORITY_TEXT_BYTES,
95 MonitorError::InvalidCondition,
96 ),
97 Self::CustomProbe { command, cwd } => {
98 valid_text(
99 command,
100 MAXIMUM_COMMAND_BYTES,
101 MonitorError::InvalidCondition,
102 )?;
103 valid_text(
104 cwd,
105 MAXIMUM_AUTHORITY_TEXT_BYTES,
106 MonitorError::InvalidCondition,
107 )
108 }
109 }
110 }
111
112 fn is_gap_sensitive(&self) -> bool {
113 matches!(
114 self,
115 Self::OutputContains { .. }
116 | Self::OutputMatches { .. }
117 | Self::OutputQuiet { .. }
118 | Self::ScreenMatches { .. }
119 )
120 }
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124#[serde(tag = "kind", rename_all = "snake_case")]
125pub enum NotifySchedule {
126 OnMatch,
127 OnStateChange,
128 OnExit,
129 EveryCheck,
130 EveryNChecks { count: u32 },
131 Interval { interval_ms: u64 },
132}
133
134#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum MonitorLifetime {
137 UntilMatch,
138 UntilSessionEnd,
139 Duration { duration_ms: u64 },
140}
141
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(deny_unknown_fields)]
144pub struct MonitorDefinition {
145 pub condition: MonitorCondition,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub check_interval_ms: Option<u64>,
148 pub notify: NotifySchedule,
149 pub lifetime: MonitorLifetime,
150}
151
152impl MonitorDefinition {
153 pub fn validate(&self) -> Result<(), MonitorError> {
154 self.condition.validate()?;
155 match &self.notify {
156 NotifySchedule::EveryNChecks { count } if *count == 0 || *count > 1_000_000 => {
157 return Err(MonitorError::InvalidSchedule);
158 }
159 NotifySchedule::Interval { interval_ms } => validate_schedule(*interval_ms)?,
160 _ => {}
161 }
162 if let MonitorLifetime::Duration { duration_ms } = self.lifetime
163 && (duration_ms == 0 || duration_ms > MAXIMUM_LIFETIME_MS)
164 {
165 return Err(MonitorError::InvalidLifetime);
166 }
167 match (self.condition.requires_polling(), self.check_interval_ms) {
168 (true, Some(interval)) => validate_schedule(interval),
169 (true, None) => Err(MonitorError::MissingCheckSchedule),
170 (false, Some(_)) => Err(MonitorError::UnexpectedCheckSchedule),
171 (false, None) => Ok(()),
172 }
173 }
174}
175
176#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
177#[serde(tag = "kind", rename_all = "snake_case")]
178pub enum MonitorOperation {
179 Add {
180 definition: MonitorDefinition,
181 },
182 Update {
183 monitor_id: String,
184 definition: MonitorDefinition,
185 },
186 Pause {
187 monitor_id: String,
188 },
189 Resume {
190 monitor_id: String,
191 },
192 Remove {
193 monitor_id: String,
194 },
195}
196
197impl MonitorOperation {
198 pub fn validate(&self) -> Result<(), MonitorError> {
199 match self {
200 Self::Add { definition } => definition.validate(),
201 Self::Update {
202 monitor_id,
203 definition,
204 } => {
205 validate_monitor_id(monitor_id)?;
206 definition.validate()
207 }
208 Self::Pause { monitor_id }
209 | Self::Resume { monitor_id }
210 | Self::Remove { monitor_id } => validate_monitor_id(monitor_id),
211 }
212 }
213}
214
215#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
216#[serde(rename_all = "snake_case")]
217pub enum MonitorState {
218 Active,
219 Paused,
220 Matched,
221 Degraded,
222}
223
224#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
225#[serde(rename_all = "snake_case")]
226pub enum EventReason {
227 Matched,
228 StateChanged,
229 SessionExit,
230 Check,
231 Interval,
232 Expired,
233 Removed,
234 Paused,
235 Resumed,
236 Updated,
237}
238
239#[derive(Clone, Copy, Debug, Eq, PartialEq)]
240pub enum Observation {
241 Check,
242 Output,
243 Screen,
244 Quiet,
245 Exit,
246 SessionExit,
247}
248
249#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
250pub struct MonitorRuntime {
251 pub state: MonitorState,
252 pub generation: u64,
253 pub created_at_ms: i64,
254 pub lifetime_deadline_ms: Option<i64>,
255 pub next_check_ms: Option<i64>,
256 pub next_notification_ms: Option<i64>,
257 pub check_count: u64,
258 pub notification_count: u64,
259 pub last_event_id: u64,
260 pub last_event_reason: Option<EventReason>,
261 pub condition_matched: bool,
262 pub path_baseline: Option<PathBaseline>,
263 pub probe_cwd_fingerprint: Option<[u8; 32]>,
264 matcher_states: [u64; PATTERN_WORDS],
265}
266
267#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
268pub struct PathBaseline {
269 pub exists: bool,
270 pub size: u64,
271 pub modified_ns: i128,
272}
273
274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
275pub struct Monitor {
276 pub monitor_id: String,
277 pub definition: MonitorDefinition,
278 pub runtime: MonitorRuntime,
279}
280
281#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
282pub struct Decision {
283 pub notify: Option<EventReason>,
284 pub remove: bool,
285 pub state_changed: bool,
286}
287
288impl Monitor {
289 pub fn new(
290 monitor_id: impl Into<String>,
291 definition: MonitorDefinition,
292 now_ms: i64,
293 ) -> Result<Self, MonitorError> {
294 definition.validate()?;
295 if now_ms < 0 {
296 return Err(MonitorError::InvalidClock);
297 }
298 let monitor_id = monitor_id.into();
299 validate_monitor_id(&monitor_id)?;
300 let lifetime_deadline_ms = match definition.lifetime {
301 MonitorLifetime::Duration { duration_ms } => Some(deadline(now_ms, duration_ms)?),
302 MonitorLifetime::UntilMatch | MonitorLifetime::UntilSessionEnd => None,
303 };
304 let next_check_ms = match (&definition.condition, definition.check_interval_ms) {
305 (_, Some(interval)) => Some(deadline(now_ms, interval)?),
306 (MonitorCondition::OutputQuiet { duration_ms }, None) => {
307 Some(deadline(now_ms, *duration_ms)?)
308 }
309 _ => None,
310 };
311 let next_notification_ms = match definition.notify {
312 NotifySchedule::Interval { interval_ms } => Some(deadline(now_ms, interval_ms)?),
313 _ => None,
314 };
315 Ok(Self {
316 monitor_id,
317 definition,
318 runtime: MonitorRuntime {
319 state: MonitorState::Active,
320 generation: 1,
321 created_at_ms: now_ms,
322 lifetime_deadline_ms,
323 next_check_ms,
324 next_notification_ms,
325 check_count: 0,
326 notification_count: 0,
327 last_event_id: 0,
328 last_event_reason: None,
329 condition_matched: false,
330 path_baseline: None,
331 probe_cwd_fingerprint: None,
332 matcher_states: [0; PATTERN_WORDS],
333 },
334 })
335 }
336
337 pub fn observe(
338 &mut self,
339 observation: Observation,
340 condition_matches: bool,
341 now_ms: i64,
342 ) -> Result<Decision, MonitorError> {
343 if matches!(
344 self.runtime.state,
345 MonitorState::Paused | MonitorState::Degraded
346 ) {
347 return Ok(Decision::default());
348 }
349 if self.expired(now_ms)? {
350 return Ok(Decision {
351 notify: self.state_notification(EventReason::Expired),
352 remove: true,
353 state_changed: true,
354 });
355 }
356 if observation == Observation::SessionExit {
357 return Ok(Decision {
358 notify: if self.definition.notify == NotifySchedule::OnExit {
359 Some(EventReason::SessionExit)
360 } else {
361 self.state_notification(EventReason::SessionExit)
362 },
363 remove: true,
364 state_changed: true,
365 });
366 }
367 self.runtime.check_count = self
368 .runtime
369 .check_count
370 .checked_add(1)
371 .ok_or(MonitorError::CounterOverflow)?;
372 if observation == Observation::Check
373 && let Some(interval) = self.definition.check_interval_ms
374 {
375 let due = self.runtime.next_check_ms.unwrap_or(now_ms);
376 self.runtime.next_check_ms = Some(advance_deadline(due, interval, now_ms)?);
377 }
378 if observation == Observation::Quiet
379 && let MonitorCondition::OutputQuiet { duration_ms } = self.definition.condition
380 {
381 self.runtime.next_check_ms = Some(deadline(now_ms, duration_ms)?);
382 }
383
384 let newly_matched = condition_matches && !self.runtime.condition_matched;
385 if newly_matched {
386 self.runtime.condition_matched = true;
387 self.runtime.state = MonitorState::Matched;
388 }
389 let notify = match self.definition.notify {
390 NotifySchedule::OnMatch if newly_matched => Some(EventReason::Matched),
391 NotifySchedule::OnStateChange if newly_matched => Some(EventReason::StateChanged),
392 NotifySchedule::EveryCheck => Some(EventReason::Check),
393 NotifySchedule::EveryNChecks { count }
394 if self.runtime.check_count.is_multiple_of(u64::from(count)) =>
395 {
396 Some(EventReason::Check)
397 }
398 _ => None,
399 };
400 Ok(Decision {
401 notify,
402 remove: newly_matched && self.definition.lifetime == MonitorLifetime::UntilMatch,
403 state_changed: newly_matched,
404 })
405 }
406
407 pub fn timer_decision(&mut self, now_ms: i64) -> Result<Decision, MonitorError> {
408 if self.expired(now_ms)? {
409 return Ok(Decision {
410 notify: self.state_notification(EventReason::Expired),
411 remove: true,
412 state_changed: true,
413 });
414 }
415 if matches!(
416 self.runtime.state,
417 MonitorState::Paused | MonitorState::Degraded
418 ) {
419 return Ok(Decision::default());
420 }
421 let NotifySchedule::Interval { interval_ms } = self.definition.notify else {
422 return Ok(Decision::default());
423 };
424 let due = self
425 .runtime
426 .next_notification_ms
427 .ok_or(MonitorError::InvalidState)?;
428 if now_ms < due {
429 return Ok(Decision::default());
430 }
431 self.runtime.next_notification_ms = Some(advance_deadline(due, interval_ms, now_ms)?);
432 Ok(Decision {
433 notify: Some(EventReason::Interval),
434 ..Decision::default()
435 })
436 }
437
438 pub fn note_output(&mut self, now_ms: i64) -> Result<(), MonitorError> {
439 if let MonitorCondition::OutputQuiet { duration_ms } = self.definition.condition {
440 self.runtime.next_check_ms = Some(deadline(now_ms, duration_ms)?);
441 }
442 Ok(())
443 }
444
445 pub fn quiet_due(&self, now_ms: i64) -> bool {
446 self.runtime.state != MonitorState::Paused
447 && matches!(
448 self.definition.condition,
449 MonitorCondition::OutputQuiet { .. }
450 )
451 && self.runtime.next_check_ms.is_some_and(|due| now_ms >= due)
452 }
453
454 pub fn polling_due(&self, now_ms: i64) -> bool {
455 !matches!(
456 self.runtime.state,
457 MonitorState::Paused | MonitorState::Degraded
458 ) && self.definition.condition.requires_polling()
459 && self.runtime.next_check_ms.is_some_and(|due| now_ms >= due)
460 }
461
462 pub fn next_deadline(&self) -> Option<i64> {
463 let mut result = self.runtime.lifetime_deadline_ms;
464 if !matches!(
465 self.runtime.state,
466 MonitorState::Paused | MonitorState::Degraded
467 ) {
468 result = earlier(result, self.runtime.next_check_ms);
469 result = earlier(result, self.runtime.next_notification_ms);
470 }
471 result
472 }
473
474 pub fn pause(&mut self) -> bool {
475 if matches!(
476 self.runtime.state,
477 MonitorState::Paused | MonitorState::Degraded
478 ) {
479 return false;
480 }
481 self.runtime.state = MonitorState::Paused;
482 true
483 }
484
485 pub fn resume(&mut self, now_ms: i64) -> Result<bool, MonitorError> {
486 if self.runtime.state != MonitorState::Paused {
487 return Ok(false);
488 }
489 self.runtime.state = if self.runtime.condition_matched {
490 MonitorState::Matched
491 } else {
492 MonitorState::Active
493 };
494 if let Some(interval) = self.definition.check_interval_ms {
495 self.runtime.next_check_ms = Some(deadline(now_ms, interval)?);
496 } else if let MonitorCondition::OutputQuiet { duration_ms } = self.definition.condition {
497 self.runtime.next_check_ms = Some(deadline(now_ms, duration_ms)?);
498 }
499 if let NotifySchedule::Interval { interval_ms } = self.definition.notify {
500 self.runtime.next_notification_ms = Some(deadline(now_ms, interval_ms)?);
501 }
502 Ok(true)
503 }
504
505 pub fn degrade_for_raw_gap(&mut self) -> Result<bool, MonitorError> {
506 if !self.definition.condition.is_gap_sensitive()
507 || self.runtime.state == MonitorState::Degraded
508 {
509 return Ok(false);
510 }
511 self.runtime.state = MonitorState::Degraded;
512 self.runtime.matcher_states.fill(0);
513 self.runtime.next_check_ms = None;
514 self.runtime.next_notification_ms = None;
515 self.bump_generation()?;
516 Ok(true)
517 }
518
519 pub fn bump_generation(&mut self) -> Result<(), MonitorError> {
520 self.runtime.generation = self
521 .runtime
522 .generation
523 .checked_add(1)
524 .ok_or(MonitorError::CounterOverflow)?;
525 Ok(())
526 }
527
528 pub fn note_notification(
529 &mut self,
530 event_id: u64,
531 reason: EventReason,
532 ) -> Result<(), MonitorError> {
533 if event_id == 0 || event_id <= self.runtime.last_event_id {
534 return Err(MonitorError::InvalidEventId);
535 }
536 self.runtime.last_event_id = event_id;
537 self.runtime.last_event_reason = Some(reason);
538 self.runtime.notification_count = self
539 .runtime
540 .notification_count
541 .checked_add(1)
542 .ok_or(MonitorError::CounterOverflow)?;
543 Ok(())
544 }
545
546 pub fn feed_output(&mut self, bytes: &[u8]) -> Result<bool, MonitorError> {
547 let (pattern, wildcard) = match &self.definition.condition {
548 MonitorCondition::OutputContains { pattern } => (pattern.as_bytes(), false),
549 MonitorCondition::OutputMatches { pattern } => (pattern.as_bytes(), true),
550 _ => return Ok(false),
551 };
552 pattern_feed(pattern, wildcard, &mut self.runtime.matcher_states, bytes)
553 }
554
555 pub fn validate(&self) -> Result<(), MonitorError> {
556 validate_monitor_id(&self.monitor_id)?;
557 self.definition.validate()?;
558 if self.runtime.generation == 0
559 || self.runtime.created_at_ms < 0
560 || (self.runtime.last_event_id > 0 && self.runtime.notification_count == 0)
561 || ((self.runtime.last_event_id == 0) != self.runtime.last_event_reason.is_none())
562 || (self.runtime.condition_matched && self.runtime.state == MonitorState::Active)
563 {
564 return Err(MonitorError::InvalidState);
565 }
566 if self
567 .runtime
568 .lifetime_deadline_ms
569 .is_some_and(|deadline| deadline <= self.runtime.created_at_ms)
570 {
571 return Err(MonitorError::InvalidState);
572 }
573 Ok(())
574 }
575
576 fn expired(&self, now_ms: i64) -> Result<bool, MonitorError> {
577 if now_ms < 0 {
578 return Err(MonitorError::InvalidClock);
579 }
580 Ok(self
581 .runtime
582 .lifetime_deadline_ms
583 .is_some_and(|deadline| now_ms >= deadline))
584 }
585
586 fn state_notification(&self, reason: EventReason) -> Option<EventReason> {
587 (self.definition.notify == NotifySchedule::OnStateChange).then_some(reason)
588 }
589}
590
591pub fn stable_id(sequence: u64) -> Result<String, MonitorError> {
592 if sequence == 0 {
593 return Err(MonitorError::IdExhausted);
594 }
595 Ok(format!("monitor-{sequence}"))
596}
597
598pub fn pattern_matches(pattern: &[u8], wildcard: bool, bytes: &[u8]) -> Result<bool, MonitorError> {
599 let mut states = [0; PATTERN_WORDS];
600 pattern_feed(pattern, wildcard, &mut states, bytes)
601}
602
603fn pattern_feed(
604 pattern: &[u8],
605 wildcard: bool,
606 states: &mut [u64; PATTERN_WORDS],
607 bytes: &[u8],
608) -> Result<bool, MonitorError> {
609 if pattern.is_empty() || pattern.len() > MAXIMUM_PATTERN_BYTES {
610 return Err(MonitorError::InvalidPattern);
611 }
612 set_bit(states, 0);
613 epsilon_closure(pattern, wildcard, states);
614 for byte in bytes {
615 let mut next = [0; PATTERN_WORDS];
616 set_bit(&mut next, 0);
617 for (index, token) in pattern.iter().copied().enumerate() {
618 if !bit_is_set(states, index) {
619 continue;
620 }
621 if wildcard && token == b'*' {
622 set_bit(&mut next, index);
623 } else if token == *byte || (wildcard && token == b'?') {
624 set_bit(&mut next, index + 1);
625 }
626 }
627 epsilon_closure(pattern, wildcard, &mut next);
628 *states = next;
629 if bit_is_set(states, pattern.len()) {
630 return Ok(true);
631 }
632 }
633 Ok(bit_is_set(states, pattern.len()))
634}
635
636fn validate_schedule(interval_ms: u64) -> Result<(), MonitorError> {
637 if !(MINIMUM_SCHEDULE_MS..=MAXIMUM_SCHEDULE_MS).contains(&interval_ms) {
638 return Err(MonitorError::InvalidSchedule);
639 }
640 Ok(())
641}
642
643fn validate_monitor_id(value: &str) -> Result<(), MonitorError> {
644 valid_text(
645 value,
646 MAXIMUM_MONITOR_ID_BYTES,
647 MonitorError::InvalidMonitor,
648 )
649}
650
651fn valid_text(value: &str, maximum: usize, error: MonitorError) -> Result<(), MonitorError> {
652 if value.is_empty() || value.len() > maximum || value.contains('\0') {
653 return Err(error);
654 }
655 Ok(())
656}
657
658fn deadline(now_ms: i64, duration_ms: u64) -> Result<i64, MonitorError> {
659 let duration = i64::try_from(duration_ms).map_err(|_| MonitorError::DeadlineOverflow)?;
660 now_ms
661 .checked_add(duration)
662 .ok_or(MonitorError::DeadlineOverflow)
663}
664
665fn advance_deadline(current: i64, interval_ms: u64, now_ms: i64) -> Result<i64, MonitorError> {
666 let interval = i64::try_from(interval_ms).map_err(|_| MonitorError::DeadlineOverflow)?;
667 if interval <= 0 {
668 return Err(MonitorError::InvalidSchedule);
669 }
670 if current > now_ms {
671 return Ok(current);
672 }
673 let elapsed = now_ms
674 .checked_sub(current)
675 .ok_or(MonitorError::DeadlineOverflow)?;
676 let steps = elapsed / interval + 1;
677 current
678 .checked_add(
679 steps
680 .checked_mul(interval)
681 .ok_or(MonitorError::DeadlineOverflow)?,
682 )
683 .ok_or(MonitorError::DeadlineOverflow)
684}
685
686fn earlier(left: Option<i64>, right: Option<i64>) -> Option<i64> {
687 match (left, right) {
688 (None, value) | (value, None) => value,
689 (Some(left), Some(right)) => Some(left.min(right)),
690 }
691}
692
693fn epsilon_closure(pattern: &[u8], wildcard: bool, states: &mut [u64; PATTERN_WORDS]) {
694 if !wildcard {
695 return;
696 }
697 for (index, token) in pattern.iter().copied().enumerate() {
698 if token == b'*' && bit_is_set(states, index) {
699 set_bit(states, index + 1);
700 }
701 }
702}
703
704fn set_bit(states: &mut [u64; PATTERN_WORDS], index: usize) {
705 states[index / 64] |= 1_u64 << (index % 64);
706}
707
708fn bit_is_set(states: &[u64; PATTERN_WORDS], index: usize) -> bool {
709 states[index / 64] & (1_u64 << (index % 64)) != 0
710}
711
712#[derive(Clone, Copy, Debug, Eq, PartialEq)]
713pub enum MonitorError {
714 InvalidCondition,
715 InvalidLifetime,
716 InvalidSchedule,
717 MissingCheckSchedule,
718 UnexpectedCheckSchedule,
719 InvalidMonitor,
720 InvalidState,
721 InvalidClock,
722 InvalidPattern,
723 InvalidEventId,
724 CounterOverflow,
725 DeadlineOverflow,
726 IdExhausted,
727}
728
729impl std::fmt::Display for MonitorError {
730 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
731 write!(
732 formatter,
733 "{}",
734 match self {
735 Self::InvalidCondition => "invalid monitor condition",
736 Self::InvalidLifetime => "invalid monitor lifetime",
737 Self::InvalidSchedule => "invalid monitor schedule",
738 Self::MissingCheckSchedule => "polling monitor requires check_interval_ms",
739 Self::UnexpectedCheckSchedule =>
740 "event-driven monitor cannot set check_interval_ms",
741 Self::InvalidMonitor => "invalid monitor",
742 Self::InvalidState => "invalid monitor state",
743 Self::InvalidClock => "invalid monitor clock",
744 Self::InvalidPattern => "invalid monitor pattern",
745 Self::InvalidEventId => "invalid monitor event id",
746 Self::CounterOverflow => "monitor counter overflow",
747 Self::DeadlineOverflow => "monitor deadline overflow",
748 Self::IdExhausted => "monitor id exhausted",
749 }
750 )
751 }
752}
753
754impl std::error::Error for MonitorError {}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 fn definition(
761 condition: MonitorCondition,
762 notify: NotifySchedule,
763 lifetime: MonitorLifetime,
764 ) -> MonitorDefinition {
765 let check_interval_ms = condition.requires_polling().then_some(25);
766 MonitorDefinition {
767 condition,
768 check_interval_ms,
769 notify,
770 lifetime,
771 }
772 }
773
774 #[test]
775 fn bounded_matcher_preserves_literal_and_wildcard_state_across_chunks() {
776 let mut literal = Monitor::new(
777 "monitor-1",
778 definition(
779 MonitorCondition::OutputContains {
780 pattern: "ready".into(),
781 },
782 NotifySchedule::OnMatch,
783 MonitorLifetime::UntilMatch,
784 ),
785 0,
786 )
787 .unwrap();
788 assert!(!literal.feed_output(b"re").unwrap());
789 assert!(literal.feed_output(b"ady").unwrap());
790
791 let mut wildcard = Monitor::new(
792 "monitor-2",
793 definition(
794 MonitorCondition::OutputMatches {
795 pattern: "sta*t?d".into(),
796 },
797 NotifySchedule::OnMatch,
798 MonitorLifetime::UntilMatch,
799 ),
800 0,
801 )
802 .unwrap();
803 assert!(!wildcard.feed_output(b"xxsta").unwrap());
804 assert!(!wildcard.feed_output(b"ble-t").unwrap());
805 assert!(wildcard.feed_output(b"edyy").unwrap());
806 assert!(pattern_matches(b"*status?ok*", true, b"prefix status-ok suffix").unwrap());
807 }
808
809 #[test]
810 fn validation_rejects_schedule_floods_mismatches_and_overflow() {
811 let mut polling = definition(
812 MonitorCondition::PathExists {
813 path: "/workspace/ready".into(),
814 },
815 NotifySchedule::OnMatch,
816 MonitorLifetime::UntilMatch,
817 );
818 polling.check_interval_ms = Some(1);
819 assert_eq!(polling.validate(), Err(MonitorError::InvalidSchedule));
820 polling.check_interval_ms = None;
821 assert_eq!(polling.validate(), Err(MonitorError::MissingCheckSchedule));
822
823 let event = MonitorDefinition {
824 condition: MonitorCondition::ProcessExit,
825 check_interval_ms: Some(25),
826 notify: NotifySchedule::OnMatch,
827 lifetime: MonitorLifetime::UntilMatch,
828 };
829 assert_eq!(event.validate(), Err(MonitorError::UnexpectedCheckSchedule));
830 assert_eq!(deadline(i64::MAX, 1), Err(MonitorError::DeadlineOverflow));
831 }
832
833 #[test]
834 fn decisions_keep_match_exit_interval_and_lifetime_independent() {
835 let mut matched = Monitor::new(
836 "monitor-1",
837 definition(
838 MonitorCondition::ProcessExit,
839 NotifySchedule::OnMatch,
840 MonitorLifetime::UntilMatch,
841 ),
842 100,
843 )
844 .unwrap();
845 assert_eq!(
846 matched.observe(Observation::Exit, true, 110).unwrap(),
847 Decision {
848 notify: Some(EventReason::Matched),
849 remove: true,
850 state_changed: true,
851 }
852 );
853
854 let mut on_exit = Monitor::new(
855 "monitor-2",
856 definition(
857 MonitorCondition::ProcessExit,
858 NotifySchedule::OnExit,
859 MonitorLifetime::UntilSessionEnd,
860 ),
861 100,
862 )
863 .unwrap();
864 assert_eq!(
865 on_exit
866 .observe(Observation::SessionExit, false, 110)
867 .unwrap()
868 .notify,
869 Some(EventReason::SessionExit)
870 );
871
872 let mut interval = Monitor::new(
873 "monitor-3",
874 definition(
875 MonitorCondition::ProcessExit,
876 NotifySchedule::Interval { interval_ms: 25 },
877 MonitorLifetime::UntilSessionEnd,
878 ),
879 100,
880 )
881 .unwrap();
882 assert_eq!(
883 interval.timer_decision(125).unwrap().notify,
884 Some(EventReason::Interval)
885 );
886 }
887
888 #[test]
889 fn quiet_pause_resume_event_dedup_and_expiry_are_deterministic() {
890 let mut quiet = Monitor::new(
891 "monitor-9",
892 definition(
893 MonitorCondition::OutputQuiet { duration_ms: 50 },
894 NotifySchedule::OnStateChange,
895 MonitorLifetime::Duration { duration_ms: 100 },
896 ),
897 1_000,
898 )
899 .unwrap();
900 quiet.note_output(1_025).unwrap();
901 assert!(!quiet.quiet_due(1_074));
902 assert!(quiet.quiet_due(1_075));
903 assert!(quiet.pause());
904 assert_eq!(quiet.next_deadline(), Some(1_100));
905 assert!(quiet.resume(1_080).unwrap());
906 quiet.note_notification(4, EventReason::Matched).unwrap();
907 assert_eq!(
908 quiet.note_notification(4, EventReason::Matched),
909 Err(MonitorError::InvalidEventId)
910 );
911 assert!(quiet.timer_decision(1_100).unwrap().remove);
912 assert_eq!(stable_id(42).unwrap(), "monitor-42");
913 }
914
915 #[test]
916 fn raw_gaps_degrade_output_monitors_but_not_process_monitors() {
917 let mut output = Monitor::new(
918 "monitor-gap",
919 definition(
920 MonitorCondition::OutputContains {
921 pattern: "needle".into(),
922 },
923 NotifySchedule::OnMatch,
924 MonitorLifetime::UntilSessionEnd,
925 ),
926 100,
927 )
928 .unwrap();
929 assert!(output.degrade_for_raw_gap().unwrap());
930 assert_eq!(output.runtime.state, MonitorState::Degraded);
931 assert_eq!(output.runtime.generation, 2);
932
933 let mut process = Monitor::new(
934 "monitor-process",
935 definition(
936 MonitorCondition::ProcessExit,
937 NotifySchedule::OnMatch,
938 MonitorLifetime::UntilSessionEnd,
939 ),
940 100,
941 )
942 .unwrap();
943 assert!(!process.degrade_for_raw_gap().unwrap());
944 assert_eq!(process.runtime.state, MonitorState::Active);
945 }
946
947 #[test]
948 fn monitor_json_uses_the_public_flat_kind_vocabulary() {
949 let definition = definition(
950 MonitorCondition::TcpReady {
951 host: "127.0.0.1".into(),
952 port: 3_000,
953 },
954 NotifySchedule::EveryNChecks { count: 2 },
955 MonitorLifetime::Duration { duration_ms: 500 },
956 );
957 let value = serde_json::to_value(&definition).unwrap();
958 assert_eq!(value["condition"]["kind"], "tcp_ready");
959 assert_eq!(value["condition"]["port"], 3_000);
960 assert_eq!(value["notify"]["kind"], "every_n_checks");
961 assert_eq!(value["lifetime"]["kind"], "duration");
962 assert_eq!(
963 serde_json::from_value::<MonitorDefinition>(value).unwrap(),
964 definition
965 );
966 }
967}