yah-tower 0.8.20

yah-tower — rule-driven monitor on scryer. Rule parser/compiler, ScryerFilter, and dispatch engine.
Documentation
//! Rule file parser: YAML and JSON → `TowerRule`.
//!
//! Operators author rules as YAML in `.yah/cloud/rules/<name>.yaml`. JSON is
//! accepted for machine-generated rules (agents, desktop). A tiny DSL authoring
//! surface is deferred to tower-2.

use tower_rules::TowerRule;

/// Error produced when a rule file cannot be parsed.
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
    #[error("YAML parse error: {0}")]
    Yaml(#[from] serde_yaml::Error),

    #[error("JSON parse error: {0}")]
    Json(#[from] serde_json::Error),
}

/// Parse a YAML rule definition into a `TowerRule`.
///
/// Operators write rules in `.yah/cloud/rules/<name>.yaml`. The YAML must
/// deserialize into a valid `TowerRule`; use `validate` afterwards to catch
/// semantic errors (e.g. missing agent_class on an AgentDispatch trigger).
pub fn parse_rule_yaml(src: &str) -> Result<TowerRule, ParseError> {
    Ok(serde_yaml::from_str(src)?)
}

/// Parse a JSON rule definition into a `TowerRule`.
///
/// Used for machine-generated rules (agent output, desktop form-builder
/// backend). Semantically equivalent to YAML parsing.
pub fn parse_rule_json(src: &str) -> Result<TowerRule, ParseError> {
    Ok(serde_json::from_str(src)?)
}

#[cfg(test)]
mod tests {
    use tower_rules::*;
    use super::*;

    #[test]
    fn parse_yaml_scryer_health_rule() {
        let yaml = r#"
schema_version: V1
id: yubaba-quorum-loss
name: "Yubaba raft quorum loss"
predicate:
  type: scryer_health
  signal: yubaba_raft_quorum_lost
trigger:
  type: notification
  channels:
    - type: desktop_badge
  severity: critical
debounce_ms: 30000
federation:
  type: local_only
enabled: true
"#;
        let rule = parse_rule_yaml(yaml).unwrap();
        assert_eq!(rule.id, RuleId("yubaba-quorum-loss".into()));
        assert!(matches!(
            rule.predicate,
            Predicate::ScryerHealth { signal: HealthSignal::YubabaRaftQuorumLost }
        ));
        assert!(matches!(
            rule.trigger,
            Trigger::Notification { severity: Severity::Critical, .. }
        ));
        assert_eq!(rule.debounce_ms, Some(30_000));
    }

    #[test]
    fn parse_yaml_event_match_rule() {
        let yaml = r#"
schema_version: V1
id: db-error-burst
name: "Database error burst"
predicate:
  type: event_match
  scope:
    type: service
    ident: "noisetable-db.pdx"
  level: error
  target:
    type: glob
    pattern: "database.*"
  rate:
    min_count: 5
    window_ms: 60000
trigger:
  type: notification
  channels:
    - type: desktop_badge
  severity: critical
federation:
  type: local_only
enabled: true
"#;
        let rule = parse_rule_yaml(yaml).unwrap();
        assert_eq!(rule.id, RuleId("db-error-burst".into()));
        assert!(matches!(
            &rule.predicate,
            Predicate::EventMatch { level: Some(LevelFilter::Error), .. }
        ));
    }

    #[test]
    fn parse_yaml_compound_and_rule() {
        let yaml = r#"
schema_version: V1
id: compound-test
name: "Compound test"
predicate:
  type: compound
  op: and
  children:
    - type: scryer_health
      signal: yubaba_raft_quorum_lost
    - type: event_match
      scope:
        type: any
      level: warn
trigger:
  type: notification
  channels:
    - type: desktop_badge
  severity: warning
federation:
  type: local_only
enabled: false
"#;
        let rule = parse_rule_yaml(yaml).unwrap();
        assert!(matches!(rule.predicate, Predicate::Compound { op: CompoundOp::And, .. }));
        assert!(!rule.enabled);
    }

    #[test]
    fn parse_json_rule() {
        let json = r#"{
            "schema_version": "V1",
            "id": "json-rule",
            "name": "JSON rule",
            "predicate": {"type": "scryer_health", "signal": "ingestion_lag"},
            "trigger": {
                "type": "notification",
                "channels": [{"type": "desktop_badge"}],
                "severity": "warning"
            },
            "federation": {"type": "local_only"},
            "enabled": true
        }"#;
        let rule = parse_rule_json(json).unwrap();
        assert_eq!(rule.id, RuleId("json-rule".into()));
        assert!(matches!(
            rule.predicate,
            Predicate::ScryerHealth { signal: HealthSignal::IngestionLag }
        ));
    }

    #[test]
    fn parse_yaml_invalid_syntax_errors() {
        let bad = "predicate: {type: [}";
        assert!(parse_rule_yaml(bad).is_err());
    }

    #[test]
    fn parse_yaml_yubaba_action_trigger() {
        let yaml = r#"
schema_version: V1
id: restart-on-crash
name: "Restart crashed workload"
predicate:
  type: event_match
  scope:
    type: service
    ident: "noisetable-api.pdx"
  level: error
  target:
    type: exact
    value: "service.crash"
trigger:
  type: yubaba_action
  kind:
    type: restart_workload
  target: "noisetable-api.pdx"
federation:
  type: local_only
enabled: true
"#;
        let rule = parse_rule_yaml(yaml).unwrap();
        assert!(matches!(rule.trigger, Trigger::YubabaAction { .. }));
    }

    #[test]
    fn parse_yaml_agent_dispatch_trigger() {
        let yaml = r#"
schema_version: V1
id: db-repair-dispatch
name: "Dispatch DB repair agent"
predicate:
  type: scryer_health
  signal: yubaba_raft_quorum_lost
trigger:
  type: agent_dispatch
  agent_class: "gnome/db-repair"
  prompt_template: "DB issue detected: {{event}}"
  placement:
    location:
      kind: local
    runtime: native
federation:
  type: local_only
enabled: false
"#;
        let rule = parse_rule_yaml(yaml).unwrap();
        assert!(matches!(rule.trigger, Trigger::AgentDispatch { .. }));
    }
}