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}