//! Bento's concrete event payload, carried on the generic //! [`ops_core::eventbus`] bus. Flat `kind`-tagged so it serializes to the wire //! shape the TUI parses (`{"kind":"step_start", ...}`). use crate::domain::{AppId, Status, Step, StepRunId, Target, Version}; use ops_core::eventbus::{self, BusEvent}; use serde::{Deserialize, Serialize}; pub type EventEnvelope = eventbus::EventEnvelope; pub type EventTx = eventbus::EventTx; impl BusEvent for Event { /// A busy build step emits `StepLogChunk` hundreds of times a second; every /// other variant is lifecycle status the operator must not miss. fn is_high_rate(&self) -> bool { matches!(self, Event::StepLogChunk { .. }) } } pub fn channel() -> EventTx { eventbus::channel() } /// Send an event without caring whether anyone is listening. `StepLogChunk` goes /// to the high-rate log channel, everything else to the status channel. pub fn emit(tx: &EventTx, event: Event) { eventbus::emit(tx, event); } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Event { /// A `/build` was accepted. BuildRequested { app: AppId, version: Version, targets: Vec, }, /// A previous in-flight run for this `(app, target)` was aborted because a /// newer request arrived. TargetAborted { app: AppId, target: Target }, TargetStart { app: AppId, version: Version, target: Target, }, StepStart { run_id: StepRunId, app: AppId, version: Version, target: Target, step: Step, }, /// A chunk of merged stdout+stderr from the step currently running. /// `run_id` ties back to the `StepStart`; `seq` is a per-run monotonic /// counter; `text` is UTF-8-lossy and NOT line-aligned. The on-disk log is /// the byte-exact source. StepLogChunk { run_id: StepRunId, seq: u32, text: String, }, StepDone { run_id: StepRunId, app: AppId, target: Target, step: Step, status: Status, }, TargetOk { app: AppId, version: Version, target: Target, artifacts: Vec, }, TargetFailed { app: AppId, version: Version, target: Target, step: Step, error: String, }, /// Notarization is the one flaky, network-bound step; retries are surfaced. NotarizeRetry { app: AppId, target: Target, attempt: u32, reason: String, }, ArtifactCollected { app: AppId, target: Target, path: String, bytes: i64, }, PublishOk { app: AppId, target: Target, channel: String, }, PublishFailed { app: AppId, target: Target, channel: String, error: String, }, } #[cfg(test)] mod tests { use super::*; use ops_core::eventbus::LOG_CAPACITY; fn target() -> Target { "macos/aarch64".parse().expect("valid target") } fn publish_failed() -> Event { Event::PublishFailed { app: AppId::new("goingson"), target: target(), channel: "stable".into(), error: "boom".into(), } } #[test] fn only_log_chunks_are_high_rate() { assert!( Event::StepLogChunk { run_id: StepRunId(1), seq: 0, text: "x".into() } .is_high_rate() ); assert!(!publish_failed().is_high_rate()); } #[tokio::test] async fn status_survives_a_log_firehose() { // A build step's chunk spam must NOT evict a publish failure. Before the // split both rode one 256-slot channel, so a lagging TUI lost the // failure alongside the noise. let tx = channel(); let mut status_rx = tx.subscribe_status(); let mut logs_rx = tx.subscribe_logs(); emit(&tx, publish_failed()); for i in 0..(LOG_CAPACITY + 50) { emit( &tx, Event::StepLogChunk { run_id: StepRunId(1), seq: i as u32, text: "x".into(), }, ); } // The status event is intact despite the log overflow. let env = status_rx.recv().await.expect("status event survived"); assert!(matches!(env.event, Event::PublishFailed { .. })); // The log channel is the one that lagged. assert!(matches!( logs_rx.recv().await, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) )); } #[tokio::test] async fn envelope_serializes_with_flat_kind() { // Contract for the WS handler + TUI's `format_event_line`. let env = EventEnvelope { at: chrono::Utc::now(), event: publish_failed(), }; let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(v["kind"], "publish_failed"); assert_eq!(v["channel"], "stable"); assert!(v.get("event").is_none()); assert!(v.get("at").is_some()); } }