Skip to main content

yah_qed/provider/
event_log.rs

1//! `event-log` release provider (R508 / W208 §4) — declares that a run's
2//! persistent QED event log should be spooled to object storage on completion.
3//!
4//! **The byte upload itself is performed by the host that owns the persistent
5//! log, not by this adapter.** The qed crate emits [`crate::QedEvent`]s to an
6//! in-memory sink; it does not own their durable on-disk form. In yah that
7//! durable log is `.yah/jit/qed/<run_id>.events.jsonl`, written and owned by
8//! the camp daemon's drain task — so the daemon performs the spool at
9//! drain-completion (where the JSONL is guaranteed fully flushed), reusing this
10//! adapter's [`EventLogConfig`] to know the destination. A standalone `qed` run
11//! with no daemon keeps no persistent log, so there is nothing to spool.
12//!
13//! Keeping the opt-in on the same [`Outcome::Provider`](crate::Outcome::Provider)
14//! seam as the vendor adapters means `qed validate`'s plan-time checks and the
15//! dashboard see it, while the bytes ship from where they live. Because the log
16//! of a *failed* multi-hour release is exactly what you want to inspect, the
17//! daemon spools on terminal completion regardless of pass/fail — declaring the
18//! outcome under either `on_success` or `on_fail` opts the run in.
19
20use async_trait::async_trait;
21use serde::Deserialize;
22
23use super::{ProviderContext, ProviderReport, ReleaseProvider};
24use crate::runner::RunnerError;
25
26/// Stable provider name referenced in pipeline TOML (`provider = "event-log"`).
27pub const EVENT_LOG_PROVIDER: &str = "event-log";
28
29/// Where to spool the run's event log. Parsed from the outcome's `with` table.
30#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
31pub struct EventLogConfig {
32    /// Storage tier — `"r2"` (default, Cloudflare R2) or `"pond"` /
33    /// `"local-sim"` (local MinIO). Matches the `provider` values the almanac
34    /// [`Outcome::Publish`](crate::Outcome::Publish) path accepts.
35    #[serde(default = "default_provider")]
36    pub provider: String,
37    /// Destination bucket (required).
38    pub bucket: String,
39    /// Optional key prefix within the bucket. The log lands at
40    /// `[<prefix>/]<run_id>.events.jsonl`.
41    #[serde(default)]
42    pub prefix: Option<String>,
43}
44
45fn default_provider() -> String {
46    "r2".to_string()
47}
48
49impl EventLogConfig {
50    /// Parse from an [`Outcome::Provider`](crate::Outcome::Provider) `with`
51    /// blob. Returns a typed config error (naming what's wrong) on a malformed
52    /// table.
53    pub fn from_with(with: &serde_json::Value) -> Result<Self, RunnerError> {
54        serde_json::from_value(with.clone()).map_err(|e| {
55            RunnerError::InvalidConfig(format!(
56                "event-log outcome `with` is malformed: {e} (need at least `bucket = \"...\"`)"
57            ))
58        })
59    }
60
61    /// The destination root (`<bucket>[/<prefix>]`) for log/dashboard display.
62    pub fn dest(&self) -> String {
63        match &self.prefix {
64            Some(p) if !p.is_empty() => format!("{}/{p}", self.bucket),
65            _ => self.bucket.clone(),
66        }
67    }
68}
69
70/// The `event-log` provider. See module docs: validates config + declares
71/// intent; the host daemon performs the actual spool of its owned JSONL. R2
72/// credentials resolve through the keystore (`cloudflare-r2-*`) exactly like
73/// [`Outcome::Publish`](crate::Outcome::Publish), not through the secrets
74/// bridge, so it declares no [`ReleaseProvider::required_slots`].
75#[derive(Debug, Default)]
76pub struct EventLogProvider;
77
78#[async_trait]
79impl ReleaseProvider for EventLogProvider {
80    fn name(&self) -> &str {
81        EVENT_LOG_PROVIDER
82    }
83
84    async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
85        // Validate the destination even on a live run — a bad `with` table
86        // should surface as a config error, not a silent no-spool. No upload
87        // here: the persistent log is owned by the host daemon (see module
88        // docs), which spools it at run-completion.
89        let cfg = EventLogConfig::from_with(ctx.config)?;
90        Ok(ProviderReport::action(format!(
91            "event log spools to {}://{}/<run_id>.events.jsonl on completion (uploaded by the daemon that owns the log)",
92            cfg.provider,
93            cfg.dest(),
94        )))
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::provider::MapSecrets;
102    use serde_json::json;
103
104    #[test]
105    fn parses_minimal_config_defaulting_provider_to_r2() {
106        let cfg = EventLogConfig::from_with(&json!({ "bucket": "yah-qed-logs" })).unwrap();
107        assert_eq!(cfg.provider, "r2");
108        assert_eq!(cfg.bucket, "yah-qed-logs");
109        assert_eq!(cfg.prefix, None);
110        assert_eq!(cfg.dest(), "yah-qed-logs");
111    }
112
113    #[test]
114    fn parses_full_config_and_builds_prefixed_dest() {
115        let cfg = EventLogConfig::from_with(
116            &json!({ "provider": "pond", "bucket": "logs", "prefix": "qed/events" }),
117        )
118        .unwrap();
119        assert_eq!(cfg.provider, "pond");
120        assert_eq!(cfg.dest(), "logs/qed/events");
121    }
122
123    #[test]
124    fn missing_bucket_is_typed_config_error() {
125        let err = EventLogConfig::from_with(&json!({ "provider": "r2" })).unwrap_err();
126        assert!(format!("{err}").contains("bucket"), "names the field: {err}");
127    }
128
129    #[tokio::test]
130    async fn dispatch_declares_intent_without_uploading() {
131        let provider = EventLogProvider::default();
132        let work = tempfile::tempdir().unwrap();
133        let cfg = json!({ "bucket": "logs", "prefix": "qed" });
134        let secrets = MapSecrets::default();
135        let ctx = ProviderContext {
136            version: "1.2.3",
137            artifacts: &[],
138            base_url: None,
139            config: &cfg,
140            work_dir: work.path(),
141            secrets: &secrets,
142            dry_run: false,
143        };
144        let report = provider.dispatch(&ctx).await.unwrap();
145        assert_eq!(report.actions.len(), 1);
146        assert!(report.actions[0].contains("logs/qed"));
147        assert!(report.published.is_empty(), "the adapter ships nothing itself");
148        assert!(report.produced.is_empty());
149    }
150}