use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use secrecy::SecretString;
use tokio::sync::mpsc;
use super::outbox::{DrainLimits, DrainOutcome, Outbox};
use super::CredentialProvider;
use super::{
body_code_is_site_license, parse_retry_after, CloudConfig, CloudError, CloudEvent, CloudState,
LICENSE_RETRY_SECS, MAX_RETRY_AFTER_SECS, SITE_LICENSE_CODES,
};
use crate::core::cloud::envelope::build_cloud_headers;
use crate::model_relay::session::{
resolve_session_scoped, Assurance, RequestSignals, SessionRegistry,
};
use crate::model_relay::wire_format::WireFormat;
const LICENSE_UNKNOWN_CODE: &str = "license_unknown";
const MAX_BATCH_BYTES: usize = 262_144;
const MAX_BATCH_EVENTS: usize = 100;
const SHUTDOWN_FLUSH_BUDGET: Duration = Duration::from_secs(3);
fn far_future() -> tokio::time::Instant {
tokio::time::Instant::now() + Duration::from_secs(86_400)
}
#[cfg(not(test))]
fn health_delay(state: &CloudState) -> Duration {
state.next_health_delay()
}
#[cfg(test)]
fn health_delay(_state: &CloudState) -> Duration {
Duration::from_millis(50)
}
fn note_recovery(degraded: &mut Option<&'static str>, state: &CloudState) {
if let Some(reason) = degraded.take() {
tracing::info!(
reason,
spooled = state.consecutive_drops(),
"cloud worker: connectivity restored"
);
}
}
fn clear_auth_error_latch(
local: &mut bool,
cloud_state: &CloudState,
openlatch_dir: &Path,
reason: &'static str,
) {
*local = false;
cloud_state.clear_auth_error();
if let Err(e) = persist_cloud_state(openlatch_dir, false) {
tracing::warn!(
error = %e,
reason,
"cloud worker: failed to persist cloud_state.json on auth clear"
);
}
}
const EMERGENCY_DROP_THRESHOLD: u64 = 100;
const EMERGENCY_WINDOW_MS: u64 = 10_000;
const EMERGENCY_RECOVERY_TICKS: u32 = 2;
pub(crate) const HIGH_WATER_TRIP_PCT: u64 = 80;
pub(crate) const HIGH_WATER_CLEAR_PCT: u64 = 50;
pub(crate) const HIGH_WATER_WINDOW_MS: u64 = 10_000;
const OUTBOX_MAX_ATTEMPTS: u32 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EmergencyTrigger {
LiveDrops,
ChannelHighWater,
}
impl EmergencyTrigger {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::LiveDrops => "live_drops",
Self::ChannelHighWater => "channel_high_water",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EmergencyTransition {
Enter,
Exit,
}
impl EmergencyTransition {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Enter => "enter",
Self::Exit => "exit",
}
}
}
fn update_emergency_mode(state: &CloudState, config: &CloudConfig, recovery_ticks: &mut u32) {
let drops = state.consecutive_live_drops();
let window_start = state.live_drops_window_start_ms();
let window_duration = super::now_unix_ms().saturating_sub(window_start);
let high_water_window_ms = state.channel_high_water_window_ms();
let channel_size = config.channel_size;
if !state.is_emergency_mode() {
let live_drop_sustained = drops > EMERGENCY_DROP_THRESHOLD
&& window_start > 0
&& window_duration > EMERGENCY_WINDOW_MS;
let high_water_sustained = high_water_window_ms > HIGH_WATER_WINDOW_MS;
if !live_drop_sustained && !high_water_sustained {
return;
}
state.set_emergency_mode(true);
*recovery_ticks = 0;
let (trigger, reported_duration) = if live_drop_sustained {
(EmergencyTrigger::LiveDrops, window_duration)
} else {
(EmergencyTrigger::ChannelHighWater, high_water_window_ms)
};
tracing::warn!(
code = crate::error::ERR_CLOUD_CHANNEL_EMERGENCY,
trigger = trigger.as_str(),
drops_in_window = drops,
window_duration_ms = reported_duration,
channel_size,
"cloud channel under emergency drop — pausing replay until backlog clears"
);
crate::telemetry::capture_global(
crate::telemetry::Event::cloud_channel_overflow_emergency(
EmergencyTransition::Enter.as_str(),
trigger.as_str(),
drops,
reported_duration,
channel_size,
channel_size,
),
);
return;
}
if drops > 0 || high_water_window_ms > 0 {
*recovery_ticks = 0;
return;
}
*recovery_ticks = recovery_ticks.saturating_add(1);
if *recovery_ticks < EMERGENCY_RECOVERY_TICKS {
return;
}
state.set_emergency_mode(false);
*recovery_ticks = 0;
tracing::info!("cloud channel emergency recovered — resuming replay");
crate::telemetry::capture_global(crate::telemetry::Event::cloud_channel_overflow_emergency(
EmergencyTransition::Exit.as_str(),
EmergencyTrigger::LiveDrops.as_str(),
0,
window_duration,
0,
channel_size,
));
}
pub fn build_cloud_client(
config: &CloudConfig,
egress: &crate::egress::EgressConfig,
) -> Result<reqwest::Client, crate::error::OlError> {
crate::egress::build_client_with(
crate::egress::Consumer::CloudWorker,
egress,
crate::egress::Timeouts {
connect: Some(Duration::from_millis(config.timeout_connect_ms)),
total: Some(Duration::from_millis(config.timeout_total_ms)),
},
)
}
#[allow(clippy::too_many_arguments)]
pub async fn run_cloud_worker(
rx: mpsc::Receiver<CloudEvent>,
credential_provider: Arc<dyn CredentialProvider>,
config: CloudConfig,
egress: crate::egress::EgressReporter,
cloud_state: CloudState,
openlatch_dir: PathBuf,
outbox: Option<Arc<Outbox>>,
shutdown: Option<tokio::sync::watch::Receiver<bool>>,
source_formats: SourceFormats,
sessions: Arc<SessionRegistry>,
) {
let mut rx = rx;
let client = match build_cloud_client(&config, egress.config()) {
Ok(c) => crate::egress::ClientHandle::of(c),
Err(e) => {
tracing::error!(code = %e.code, error = %e.message, "cloud worker http client init failed; cloud forwarding is off");
return;
}
};
run_cloud_worker_on(
&mut rx,
credential_provider,
config,
egress,
cloud_state,
openlatch_dir,
outbox,
shutdown,
client,
source_formats,
sessions,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_cloud_worker_on(
rx: &mut mpsc::Receiver<CloudEvent>,
credential_provider: Arc<dyn CredentialProvider>,
config: CloudConfig,
egress: crate::egress::EgressReporter,
cloud_state: CloudState,
openlatch_dir: PathBuf,
outbox: Option<Arc<Outbox>>,
shutdown: Option<tokio::sync::watch::Receiver<bool>>,
client: crate::egress::ClientHandle,
source_formats: SourceFormats,
sessions: Arc<SessionRegistry>,
) {
let (_shutdown_keepalive, mut shutdown_rx) = match shutdown {
Some(rx) => (None, rx),
None => {
let (tx, rx) = tokio::sync::watch::channel(false);
(Some(tx), rx)
}
};
let credential_poll_interval = Duration::from_millis(config.credential_poll_interval_ms);
let provider = credential_provider.clone();
let mut current_key: Option<SecretString> =
tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
cloud_state.set_no_credential(current_key.is_none());
let mut auth_error = false;
if let Err(e) = persist_cloud_state(&openlatch_dir, auth_error) {
tracing::warn!(
error = %e,
"cloud worker: failed to persist initial cloud_state.json"
);
}
let mut missing_key_warned = current_key.is_none();
let mut degraded: Option<&'static str> = None;
if current_key.is_none() {
tracing::warn!(
code = "OL-1200",
"cloud worker: no API key available — cloud forwarding disabled until you run \
'openlatch system auth login' or set [cloud] enabled = false in config.toml (fail-open, \
events are still logged locally)"
);
}
let mut last_credential_poll = tokio::time::Instant::now();
let mut last_health_tick = tokio::time::Instant::now();
let mut emergency_recovery_ticks: u32 = 0;
let size_trigger = config.batch_max_events.max(1);
let mut buf: Vec<CloudEvent> = Vec::with_capacity(size_trigger);
let mut deadline: Option<tokio::time::Instant> = None;
let flush_ctx = FlushCtx {
client: &client,
config: &config,
openlatch_dir: &openlatch_dir,
outbox: outbox.as_ref(),
cloud_state: &cloud_state,
egress: &egress,
formats: &source_formats,
sessions: &sessions,
credential_provider: &credential_provider,
};
loop {
if last_credential_poll.elapsed() >= credential_poll_interval {
let provider = credential_provider.clone();
let new_key = tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
let key_changed = match (¤t_key, &new_key) {
(None, None) => false,
(Some(_), None) | (None, Some(_)) => true,
(Some(old), Some(new)) => {
use secrecy::ExposeSecret;
old.expose_secret() != new.expose_secret()
}
};
if new_key.is_some() {
let out_of_band_clear = !key_changed && auth_error && !cloud_state.is_auth_error();
if key_changed {
tracing::info!(
"cloud worker: credential refreshed — resetting auth_error state"
);
clear_auth_error_latch(
&mut auth_error,
&cloud_state,
&openlatch_dir,
"credential refresh",
);
} else if out_of_band_clear {
clear_auth_error_latch(
&mut auth_error,
&cloud_state,
&openlatch_dir,
"out-of-band auth clear",
);
}
missing_key_warned = false;
current_key = new_key;
}
cloud_state.set_no_credential(current_key.is_none());
last_credential_poll = tokio::time::Instant::now();
}
let flush_at = deadline.unwrap_or_else(far_future);
tokio::select! {
biased;
_ = tokio::time::sleep_until(flush_at), if deadline.is_some() => {
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"time",
&mut auth_error,
&mut degraded,
).await;
deadline = None;
continue;
}
_ = shutdown_rx.changed() => {
tracing::info!("cloud worker: shutdown signalled, flushing in-flight batch");
let queued = rx.len();
for _ in 0..queued {
let Ok(event) = rx.try_recv() else { break };
if auth_error {
spool_event(
outbox.as_ref(),
&event,
&source_formats,
&sessions,
config.host_id.as_deref(),
SpoolReason::AuthError,
);
continue;
}
if current_key.is_none() {
spool_event(
outbox.as_ref(),
&event,
&source_formats,
&sessions,
config.host_id.as_deref(),
SpoolReason::NoCredential,
);
continue;
}
buf.push(event);
}
break;
}
_ = tokio::time::sleep_until(
last_credential_poll + credential_poll_interval
) => {
continue;
}
_ = tokio::time::sleep_until(
last_health_tick + health_delay(&cloud_state)
) => {
last_health_tick = tokio::time::Instant::now();
if health_probe_due(&egress) {
if let Some(probe_client) = client.current() {
run_health_probe(
&probe_client,
&config,
&cloud_state,
&egress,
&mut degraded,
)
.await;
}
}
update_emergency_mode(&cloud_state, &config, &mut emergency_recovery_ticks);
continue;
}
maybe = rx.recv() => {
let Some(event) = maybe else {
tracing::info!("cloud worker: channel closed, exiting");
break;
};
if auth_error {
tracing::debug!(
code = "OL-1201",
"cloud worker: auth_error active — spooling event to outbox until credential refresh"
);
spool_event(
outbox.as_ref(),
&event,
&source_formats,
&sessions,
config.host_id.as_deref(),
SpoolReason::AuthError,
);
continue;
}
if current_key.is_none() {
if !missing_key_warned {
tracing::warn!(
code = "OL-1200",
"cloud worker: no API key available — cloud forwarding disabled until \
you run 'openlatch system auth login' or set [cloud] enabled = false in \
config.toml (fail-open, events are still logged locally)"
);
missing_key_warned = true;
} else {
tracing::debug!(
code = "OL-1200",
"cloud worker: skipping event — still no API key available"
);
}
spool_event(
outbox.as_ref(),
&event,
&source_formats,
&sessions,
config.host_id.as_deref(),
SpoolReason::NoCredential,
);
continue;
}
if buf.is_empty() {
deadline = Some(
tokio::time::Instant::now()
+ Duration::from_millis(config.batch_max_wait_ms),
);
}
buf.push(event);
if buf.len() >= size_trigger {
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"size",
&mut auth_error,
&mut degraded,
).await;
deadline = None;
}
}
}
}
let snapshot = buf.clone();
if tokio::time::timeout(
SHUTDOWN_FLUSH_BUDGET,
flush_batch(
&flush_ctx,
&mut buf,
current_key.as_ref(),
"shutdown",
&mut auth_error,
&mut degraded,
),
)
.await
.is_err()
{
tracing::warn!(
code = "OL-1200",
buffered = snapshot.len(),
"cloud worker: shutdown flush exceeded its budget — spooling the batch to the outbox"
);
for event in &snapshot {
spool_event(
flush_ctx.outbox,
event,
flush_ctx.formats,
flush_ctx.sessions,
flush_ctx.config.host_id.as_deref(),
SpoolReason::Network,
);
}
}
}
struct FlushCtx<'a> {
client: &'a crate::egress::ClientHandle,
config: &'a CloudConfig,
openlatch_dir: &'a Path,
outbox: Option<&'a Arc<Outbox>>,
cloud_state: &'a CloudState,
egress: &'a crate::egress::EgressReporter,
formats: &'a SourceFormats,
sessions: &'a SessionRegistry,
credential_provider: &'a Arc<dyn CredentialProvider>,
}
fn gate_and_spool(
ctx: &FlushCtx<'_>,
group: &[PreparedEvent],
code: String,
licensing_url: Option<String>,
retry_after: Option<Duration>,
) {
let entering = ctx.cloud_state.license_gate().is_none();
let retry_in_secs = retry_after
.unwrap_or(Duration::from_secs(LICENSE_RETRY_SECS))
.as_secs();
ctx.cloud_state
.set_license_gate(code.clone(), licensing_url.clone(), retry_after);
if entering {
tracing::warn!(
code = crate::error::ERR_CLOUD_LICENSE_BLOCKED,
license_code = %code,
licensing_url = licensing_url.as_deref(),
retry_in_secs,
batch_size = group.len(),
"cloud worker: the platform refused ingest on licensing grounds — events are queued, enforcement continues"
);
} else {
tracing::debug!(
code = crate::error::ERR_CLOUD_LICENSE_BLOCKED,
license_code = %code,
"cloud worker: still license-blocked — queueing"
);
}
if ctx.outbox.is_some() {
spool_group(ctx.outbox, group, SpoolReason::LicenseRefused);
} else {
ctx.cloud_state.record_drops(group.len() as u64);
}
}
fn clear_license_gate_on_success(cloud_state: &CloudState) {
if cloud_state.clear_license_gate() {
tracing::warn!("cloud worker: license gate cleared — forwarding resumed");
}
}
async fn flush_batch(
ctx: &FlushCtx<'_>,
buf: &mut Vec<CloudEvent>,
key: Option<&SecretString>,
reason: &'static str,
auth_error: &mut bool,
degraded: &mut Option<&'static str>,
) {
if buf.is_empty() {
return;
}
let events = std::mem::take(buf);
tracing::debug!(
batch_size = events.len(),
reason,
"cloud worker: flushing event batch"
);
let prepared = prepare_batch(
&events,
ctx.cloud_state,
ctx.formats,
ctx.sessions,
ctx.config.host_id.as_deref(),
);
if prepared.is_empty() {
return;
}
let Some(client) = ctx.client.current() else {
tracing::debug!(
code = crate::error::ERR_DIRECT_FORBIDDEN,
batch_size = prepared.len(),
"cloud worker: no egress route is permitted; spooling instead of forwarding"
);
spool_group(ctx.outbox, &prepared, SpoolReason::Network);
return;
};
let Some(key) = key else {
spool_group(ctx.outbox, &prepared, SpoolReason::NoCredential);
return;
};
for group in split_batches(&prepared, ctx.config.batch_max_events) {
if *auth_error {
spool_group(ctx.outbox, group, SpoolReason::AuthError);
continue;
}
if ctx.cloud_state.license_gate_active() {
if ctx.outbox.is_some() {
spool_group(ctx.outbox, group, SpoolReason::LicenseRefused);
} else {
ctx.cloud_state.record_drops(group.len() as u64);
}
continue;
}
let n = group.len() as u64;
match post_batch(
&client,
ctx.config,
key,
group,
ctx.openlatch_dir,
ctx.egress,
)
.await
{
Ok(()) => {
note_recovery(degraded, ctx.cloud_state);
clear_license_gate_on_success(ctx.cloud_state);
tracing::debug!(batch_size = n, "cloud worker: batch forwarded successfully");
ctx.cloud_state.record_successful_forwards(n);
ctx.cloud_state.notify_drain();
}
Err(CloudError::AuthError) => {
tracing::warn!(
code = "OL-1201",
batch_size = n,
"cloud worker: auth error (401/403) — pausing POSTs until credential refresh"
);
ctx.credential_provider.invalidate();
*auth_error = true;
ctx.cloud_state
.auth_error
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = persist_cloud_state(ctx.openlatch_dir, true) {
tracing::warn!(error = %e, "cloud worker: failed to persist cloud_state.json");
}
spool_group(ctx.outbox, group, SpoolReason::AuthError);
}
Err(CloudError::RateLimit { retry_after_secs }) => {
tracing::debug!(
code = "OL-1202",
retry_after_secs,
batch_size = n,
"cloud worker: rate limited (429) — backing off and retrying"
);
tokio::time::sleep(Duration::from_secs(retry_after_secs)).await;
match post_batch(
&client,
ctx.config,
key,
group,
ctx.openlatch_dir,
ctx.egress,
)
.await
{
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
}) => gate_and_spool(ctx, group, code, licensing_url, retry_after),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::RateLimit);
}
}
}
Err(CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
}) => gate_and_spool(ctx, group, code, licensing_url, retry_after),
Err(CloudError::ServerError) => {
if degraded.is_none() {
tracing::warn!(
code = "OL-1200",
batch_size = n,
"cloud worker: server error (5xx) — retrying once; suppressing further warnings until recovery"
);
*degraded = Some("server_error");
} else {
tracing::debug!(code = "OL-1200", "cloud worker: 5xx during degraded streak");
}
tokio::time::sleep(Duration::from_millis(ctx.config.retry_delay_ms)).await;
match post_batch(
&client,
ctx.config,
key,
group,
ctx.openlatch_dir,
ctx.egress,
)
.await
{
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
}) => gate_and_spool(ctx, group, code, licensing_url, retry_after),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::ServerError);
}
}
}
Err(CloudError::Network) => {
if degraded.is_none() {
tracing::warn!(
code = "OL-1200",
batch_size = n,
"cloud worker: network error — retrying once; suppressing further warnings until recovery"
);
*degraded = Some("network");
} else {
tracing::debug!(
code = "OL-1200",
"cloud worker: network error during degraded streak"
);
}
tokio::time::sleep(Duration::from_millis(ctx.config.retry_delay_ms)).await;
match post_batch(
&client,
ctx.config,
key,
group,
ctx.openlatch_dir,
ctx.egress,
)
.await
{
Ok(()) => ctx.cloud_state.record_successful_forwards(n),
Err(CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
}) => gate_and_spool(ctx, group, code, licensing_url, retry_after),
Err(_) => {
ctx.cloud_state.record_drops(n);
spool_group(ctx.outbox, group, SpoolReason::Network);
}
}
}
Err(CloudError::ClientError(code)) => {
tracing::warn!(
http_status = code,
batch_size = n,
"cloud worker: unexpected 4xx — dropping batch (no retry)"
);
ctx.cloud_state.record_drops(n);
}
Err(CloudError::CompatibilityUnavailable) => {
tracing::warn!(
batch_size = n,
"cloud worker: decision-event compatibility unavailable — retaining events for renegotiation"
);
spool_group(ctx.outbox, group, SpoolReason::Compatibility);
ctx.cloud_state.policy_refresh_notify.notify_one();
}
}
}
}
fn health_url(config: &CloudConfig) -> String {
format!("{}/api/v1/health", config.api_url.trim_end_matches('/'))
}
async fn cloud_health_check(
client: &reqwest::Client,
config: &CloudConfig,
) -> Result<(), reqwest::Error> {
let resp = client.get(health_url(config)).send().await?;
resp.error_for_status().map(|_| ())
}
fn health_probe_due(egress: &crate::egress::EgressReporter) -> bool {
egress.state().is_none_or(|state| state.is_idle())
}
async fn run_health_probe(
client: &reqwest::Client,
config: &CloudConfig,
cloud_state: &CloudState,
egress: &crate::egress::EgressReporter,
degraded: &mut Option<&'static str>,
) {
let _probing = egress.state().map(|state| state.probe_guard());
let url = health_url(config);
for attempt in 0..2u8 {
match cloud_health_check(client, config).await {
Ok(()) => {
note_recovery(degraded, cloud_state);
cloud_state.record_health_ok();
egress.record_ok(&url);
cloud_state.notify_drain();
return;
}
Err(e) => {
cloud_state.record_probe_failure();
egress.record_failure(&url, &e);
tracing::debug!(
error = %e,
attempt = attempt + 1,
consecutive_failures = cloud_state.consecutive_probe_failures(),
"cloud health check failed"
);
}
}
}
}
#[derive(Debug, Clone, Copy)]
enum SpoolReason {
Network,
ServerError,
RateLimit,
AuthError,
NoCredential,
LicenseRefused,
Compatibility,
}
impl SpoolReason {
fn as_str(self) -> &'static str {
match self {
SpoolReason::Network => "network",
SpoolReason::ServerError => "server_error",
SpoolReason::RateLimit => "rate_limit",
SpoolReason::AuthError => "auth_error",
SpoolReason::NoCredential => "no_credential",
SpoolReason::LicenseRefused => "license_refused",
SpoolReason::Compatibility => "compatibility",
}
}
}
fn spool_event(
outbox: Option<&Arc<Outbox>>,
event: &CloudEvent,
formats: &SourceFormats,
sessions: &SessionRegistry,
host_id: Option<&str>,
reason: SpoolReason,
) {
if outbox.is_none() {
return;
}
spool_envelope(
outbox,
&stamp_extensions(event, formats, sessions, host_id),
reason,
);
}
fn spool_envelope(outbox: Option<&Arc<Outbox>>, envelope: &serde_json::Value, reason: SpoolReason) {
let Some(outbox) = outbox else { return };
match outbox.append(envelope) {
Ok(()) => {
crate::telemetry::capture_global(crate::telemetry::Event::cloud_event_spooled(
reason.as_str(),
));
}
Err(e) => {
tracing::warn!(
code = crate::error::ERR_OUTBOX_WRITE_FAILED,
error = %e,
path = %outbox.path().display(),
"cloud worker: failed to spool event to outbox"
);
}
}
}
fn spool_group(outbox: Option<&Arc<Outbox>>, group: &[PreparedEvent], reason: SpoolReason) {
if outbox.is_none() {
return;
}
for prepared in group {
spool_envelope(outbox, &prepared.envelope, reason);
}
}
#[derive(Debug, Clone, Default)]
pub struct SourceFormats(Arc<std::sync::RwLock<BTreeMap<&'static str, WireFormat>>>);
impl SourceFormats {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, source: &str) -> Option<WireFormat> {
match self.0.read() {
Ok(map) => map.get(source).copied(),
Err(poisoned) => poisoned.into_inner().get(source).copied(),
}
}
pub fn set(&self, source: &'static str, format: WireFormat) {
match self.0.write() {
Ok(mut map) => {
map.insert(source, format);
}
Err(poisoned) => {
poisoned.into_inner().insert(source, format);
}
}
}
}
impl FromIterator<(&'static str, WireFormat)> for SourceFormats {
fn from_iter<T: IntoIterator<Item = (&'static str, WireFormat)>>(iter: T) -> Self {
Self(Arc::new(std::sync::RwLock::new(iter.into_iter().collect())))
}
}
fn wire_format_for_source(
envelope: &serde_json::Value,
formats: &SourceFormats,
) -> Option<WireFormat> {
let source = envelope.get("source")?.as_str()?;
formats.get(source)
}
fn stamp_extensions(
event: &CloudEvent,
formats: &SourceFormats,
sessions: &SessionRegistry,
host_id: Option<&str>,
) -> serde_json::Value {
let mut envelope = event.envelope.clone();
if let Some(obj) = envelope.as_object_mut() {
obj.insert(
"agentid".to_string(),
serde_json::Value::String(event.agent_id.clone()),
);
if let Some(host_id) = host_id {
obj.entry("hostid")
.or_insert_with(|| serde_json::Value::String(host_id.to_string()));
}
obj.insert(
"clientversion".to_string(),
serde_json::Value::String(env!("OPENLATCH_VERSION").to_string()),
);
let producer_named_it = obj
.get("wireformat")
.and_then(|value| value.as_str())
.is_some_and(|format| !format.is_empty());
if !producer_named_it {
let fmt =
wire_format_for_source(&event.envelope, formats).unwrap_or(WireFormat::Unknown);
obj.insert(
"wireformat".to_string(),
serde_json::Value::String(fmt.as_str().to_string()),
);
}
stamp_session(obj, &event.agent_id, sessions);
}
envelope
}
fn stamp_session(
obj: &mut serde_json::Map<String, serde_json::Value>,
agent_id: &str,
sessions: &SessionRegistry,
) {
let has_subject = obj
.get("subject")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty());
if has_subject {
return;
}
let source = obj.get("source").and_then(serde_json::Value::as_str);
let resolved = resolve_session_scoped(sessions, agent_id, &RequestSignals::default(), source);
let (subject, assurance) = match resolved.session_id {
Some(session_id) => (session_id, resolved.assurance),
None => (agent_id.to_string(), Assurance::Unknown),
};
obj.insert("subject".to_string(), serde_json::Value::String(subject));
let data = obj
.entry("data")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if data.is_null() {
*data = serde_json::Value::Object(serde_json::Map::new());
}
if let Some(map) = data.as_object_mut() {
map.insert(
crate::model_relay::session::ASSURANCE_KEY.to_string(),
serde_json::Value::String(assurance.as_str().to_string()),
);
}
}
struct PreparedEvent {
json: String,
envelope: serde_json::Value,
}
fn prepare_one(envelope: serde_json::Value) -> Option<PreparedEvent> {
let json = match serde_json::to_string(&envelope) {
Ok(json) => json,
Err(e) => {
tracing::warn!(
code = crate::error::ERR_EVENT_TOO_LARGE,
error = %e,
"cloud worker: dropping event that cannot be serialized"
);
return None;
}
};
if json.len() + 2 > MAX_BATCH_BYTES {
tracing::debug!(
code = crate::error::ERR_EVENT_TOO_LARGE,
bytes = json.len(),
max_bytes = MAX_BATCH_BYTES,
"cloud worker: dropping oversized event — it cannot fit in any batch"
);
return None;
}
Some(PreparedEvent { json, envelope })
}
fn prepare_batch(
events: &[CloudEvent],
cloud_state: &CloudState,
formats: &SourceFormats,
sessions: &SessionRegistry,
host_id: Option<&str>,
) -> Vec<PreparedEvent> {
let mut out = Vec::with_capacity(events.len());
for event in events {
match prepare_one(stamp_extensions(event, formats, sessions, host_id)) {
Some(prepared) => out.push(prepared),
None => cloud_state.record_drop(),
}
}
out
}
fn split_batches(prepared: &[PreparedEvent], batch_max_events: usize) -> Vec<&[PreparedEvent]> {
let cap = batch_max_events.clamp(1, MAX_BATCH_EVENTS);
let mut out = Vec::new();
let mut start = 0usize;
let mut payload = 0usize;
let mut count = 0usize;
for (i, event) in prepared.iter().enumerate() {
if count > 0 {
let framed = payload + event.json.len() + 2 + count;
if count >= cap || framed > MAX_BATCH_BYTES {
out.push(&prepared[start..i]);
start = i;
payload = 0;
count = 0;
}
}
payload += event.json.len();
count += 1;
}
if count > 0 {
out.push(&prepared[start..]);
}
out
}
#[allow(clippy::too_many_arguments)]
pub async fn run_outbox_drain_on(
outbox: Arc<Outbox>,
client: crate::egress::ClientHandle,
config: CloudConfig,
credential_provider: Arc<dyn CredentialProvider>,
cloud_state: CloudState,
openlatch_dir: PathBuf,
source_formats: SourceFormats,
sessions: Arc<SessionRegistry>,
egress: crate::egress::EgressReporter,
mut shutdown: tokio::sync::watch::Receiver<bool>,
#[cfg(test)] fail_once: Option<Arc<std::sync::atomic::AtomicBool>>,
) {
let agent_id = crate::config::sniff_agent_id(&openlatch_dir).unwrap_or_default();
let attempts: Arc<std::sync::Mutex<HashMap<String, u32>>> =
Arc::new(std::sync::Mutex::new(HashMap::new()));
let notify = cloud_state.outbox_drain_notify.clone();
let ctx = OutboxDrainCtx {
outbox: &outbox,
client: &client,
config: &config,
credential_provider: &credential_provider,
cloud_state: &cloud_state,
openlatch_dir: &openlatch_dir,
agent_id: &agent_id,
attempts: &attempts,
egress: &egress,
formats: &source_formats,
sessions: &sessions,
#[cfg(test)]
fail_once: fail_once.as_deref(),
};
maybe_panic_for_test(&ctx);
drain_outbox_once(&ctx).await;
loop {
tokio::select! {
biased;
_ = shutdown.wait_for(|stop| *stop) => {
tracing::info!("outbox drain: shutdown signalled, stopping");
return;
}
_ = notify.notified() => {}
}
if cloud_state.is_emergency_mode() {
tracing::debug!("outbox drain: skipping pass — cloud channel in emergency mode");
continue;
}
if cloud_state.license_gate_active() {
tracing::debug!("outbox drain: skipping pass — license gate active");
continue;
}
maybe_panic_for_test(&ctx);
drain_outbox_once(&ctx).await;
}
}
#[cfg(test)]
fn maybe_panic_for_test(ctx: &OutboxDrainCtx<'_>) {
if let Some(flag) = ctx.fail_once {
if flag.swap(false, std::sync::atomic::Ordering::SeqCst) {
panic!("injected outbox-drain failure (test)");
}
}
}
#[cfg(not(test))]
#[inline(always)]
fn maybe_panic_for_test(_ctx: &OutboxDrainCtx<'_>) {}
struct OutboxDrainCtx<'a> {
outbox: &'a Arc<Outbox>,
client: &'a crate::egress::ClientHandle,
config: &'a CloudConfig,
credential_provider: &'a Arc<dyn CredentialProvider>,
cloud_state: &'a CloudState,
openlatch_dir: &'a Path,
agent_id: &'a str,
attempts: &'a Arc<std::sync::Mutex<HashMap<String, u32>>>,
egress: &'a crate::egress::EgressReporter,
formats: &'a SourceFormats,
sessions: &'a Arc<SessionRegistry>,
#[cfg(test)]
fail_once: Option<&'a std::sync::atomic::AtomicBool>,
}
async fn drain_outbox_once(ctx: &OutboxDrainCtx<'_>) {
let provider = ctx.credential_provider.clone();
let key: Option<SecretString> = tokio::task::spawn_blocking(move || provider.retrieve())
.await
.unwrap_or(None);
let Some(key) = key else {
return;
};
let Some(drain_client) = ctx.client.current() else {
tracing::debug!(
code = crate::error::ERR_DIRECT_FORBIDDEN,
"outbox drain: no egress route is permitted; entries stay queued"
);
return;
};
let limits = DrainLimits {
max_entries: ctx.config.batch_max_events.clamp(1, MAX_BATCH_EVENTS),
max_bytes: MAX_BATCH_BYTES,
};
let stats = ctx
.outbox
.drain(limits, |envelopes| {
let client = drain_client.clone();
let config = ctx.config.clone();
let key = key.clone();
let openlatch_dir = ctx.openlatch_dir.to_path_buf();
let agent_id = ctx.agent_id.to_string();
let attempts = ctx.attempts.clone();
let drain_egress = ctx.egress.clone();
let formats = ctx.formats.clone();
let sessions = ctx.sessions.clone();
let cloud_state = ctx.cloud_state.clone();
async move {
let group_len = envelopes.len();
let mut outcomes = vec![DrainOutcome::Forwarded; group_len];
let mut ids: Vec<Option<String>> = Vec::with_capacity(group_len);
let mut prepared: Vec<PreparedEvent> = Vec::with_capacity(group_len);
for (idx, mut envelope) in envelopes.into_iter().enumerate() {
ids.push(
envelope
.get("id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string),
);
super::outbox::strip_retry_metadata(&mut envelope);
let event = CloudEvent {
envelope,
agent_id: agent_id.clone(),
};
match prepare_one(stamp_extensions(
&event,
&formats,
&sessions,
config.host_id.as_deref(),
)) {
Some(p) => prepared.push(p),
None => outcomes[idx] = DrainOutcome::Quarantined,
}
}
let mut failure: Option<CloudError> = None;
for batch in split_batches(&prepared, config.batch_max_events) {
match post_batch(&client, &config, &key, batch, &openlatch_dir, &drain_egress)
.await
{
Ok(()) => clear_license_gate_on_success(&cloud_state),
Err(CloudError::ClientError(code)) => {
tracing::warn!(
http_status = code,
batch_size = batch.len(),
"outbox drain: dropping entries on unexpected 4xx"
);
}
Err(e) => {
if let CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
} = &e
{
cloud_state.set_license_gate(
code.clone(),
licensing_url.clone(),
*retry_after,
);
}
failure = Some(e);
break;
}
}
}
if matches!(failure, Some(CloudError::LicenseRefused { .. })) {
tracing::debug!(
"outbox drain: halted on a license refusal — entries retained, no attempt charged"
);
return Err(());
}
let Some(e) = failure else {
let mut guard = attempts.lock().unwrap();
if !guard.is_empty() {
for id in ids.iter().flatten() {
guard.remove(id);
}
}
return Ok(outcomes);
};
if matches!(e, CloudError::CompatibilityUnavailable) {
ctx.cloud_state.policy_refresh_notify.notify_one();
tracing::debug!(
batch_size = group_len,
"outbox drain: compatibility unavailable — retaining entries without charging attempts"
);
return Err(());
}
let mut all_exhausted = true;
{
let mut guard = attempts.lock().unwrap();
for id in &ids {
let Some(id) = id else {
all_exhausted = false;
continue;
};
let entry = guard.entry(id.clone()).or_insert(0);
*entry = entry.saturating_add(1);
if *entry < OUTBOX_MAX_ATTEMPTS {
all_exhausted = false;
}
}
}
if all_exhausted {
let mut guard = attempts.lock().unwrap();
for id in ids.iter().flatten() {
tracing::warn!(
code = crate::error::ERR_OUTBOX_QUARANTINED,
event_id = %id,
attempts = OUTBOX_MAX_ATTEMPTS,
error = %e,
"outbox: quarantining repeatedly-failing entry"
);
guard.remove(id);
}
return Ok(vec![DrainOutcome::Quarantined; group_len]);
}
tracing::debug!(
error = %e,
batch_size = group_len,
"outbox drain: halted on transient failure — retaining remaining entries"
);
Err(())
}
})
.await;
match stats {
Ok(stats) => {
if stats.expired > 0 {
tracing::warn!(
code = crate::error::ERR_OUTBOX_EXPIRED,
expired = stats.expired,
remaining = ctx.outbox.pending_count(),
"outbox: terminally expired retained compatibility evidence"
);
}
if stats.drained > 0
|| stats.failed > 0
|| stats.corrupt > 0
|| stats.quarantined > 0
|| stats.expired > 0
{
tracing::info!(
drained = stats.drained,
failed = stats.failed,
corrupt = stats.corrupt,
quarantined = stats.quarantined,
expired = stats.expired,
remaining = ctx.outbox.pending_count(),
"outbox drain completed"
);
crate::telemetry::capture_global(crate::telemetry::Event::cloud_outbox_drained(
stats.drained,
stats.failed,
stats.corrupt,
stats.quarantined,
));
}
if stats.drained > 0 {
ctx.cloud_state
.forwarded_count
.fetch_add(stats.drained, std::sync::atomic::Ordering::Relaxed);
}
if stats.failed > 0 {
tracing::warn!(
code = crate::error::ERR_OUTBOX_DRAIN_PARTIAL,
failed = stats.failed,
remaining = ctx.outbox.pending_count(),
"outbox drain: partial — some entries could not be replayed yet"
);
}
}
Err(e) => {
tracing::warn!(
code = crate::error::ERR_OUTBOX_DRAIN_PARTIAL,
error = %e,
"outbox drain: I/O error while replaying"
);
}
}
}
async fn post_batch(
client: &reqwest::Client,
config: &CloudConfig,
key: &SecretString,
batch: &[PreparedEvent],
openlatch_dir: &Path,
egress: &crate::egress::EgressReporter,
) -> Result<(), CloudError> {
let mut selected_version = crate::core::protocol::contracts::read_compatibility(openlatch_dir)
.ok()
.and_then(|state| {
state
.selections
.get(crate::core::protocol::contracts::DECISION_EVENT_FAMILY)
.map(|selection| selection.version)
})
.unwrap_or_else(|| {
crate::core::protocol::contracts::baseline(
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
)
.expect("catalogue validates one decision-event baseline")
});
if !batch.iter().all(|event| {
crate::core::protocol::contracts::decision_event_can_write(
&event.envelope,
selected_version,
)
}) {
selected_version =
renegotiate_event_writer(client, config, key, openlatch_dir, egress).await?;
}
if !batch.iter().all(|event| {
crate::core::protocol::contracts::decision_event_can_write(
&event.envelope,
selected_version,
)
}) {
record_event_compatibility(
openlatch_dir,
None,
&format!("decision_event v{selected_version} would discard record-v2 evidence"),
);
return Err(CloudError::CompatibilityUnavailable);
}
let request_id = uuid::Uuid::now_v7().to_string();
let mut body = String::with_capacity(batch.iter().map(|e| e.json.len() + 1).sum::<usize>() + 2);
body.push('[');
for (i, event) in batch.iter().enumerate() {
if i > 0 {
body.push(',');
}
body.push_str(&event.json);
}
body.push(']');
let headers = build_cloud_headers(
key,
&request_id,
config.host_key.as_deref(),
config.agent_id.as_deref(),
);
let base = config.api_url.trim_end_matches('/');
let url = format!("{base}/api/v1/events/ingest");
let response = match client
.post(&url)
.headers(headers)
.header(
crate::core::protocol::contracts::SUPPORT_HEADER,
crate::core::protocol::contracts::support_header_value(),
)
.body(body)
.send()
.await
{
Ok(response) => {
egress.record_ok(&url);
response
}
Err(e) => {
egress.record_failure(&url, &e);
return Err(CloudError::Network);
}
};
let status = response.status();
if status.is_success() {
if let Some(raw) = response
.headers()
.get(crate::core::protocol::contracts::SELECTED_HEADER)
{
let selected = raw
.to_str()
.ok()
.and_then(|raw| crate::core::protocol::contracts::parse_selected(raw).ok())
.and_then(|map| {
map.get(crate::core::protocol::contracts::DECISION_EVENT_FAMILY)
.copied()
});
let Some(selected) = selected else {
record_event_compatibility(
openlatch_dir,
None,
"invalid or incomplete selected-map acknowledgement",
);
return Err(CloudError::CompatibilityUnavailable);
};
if !crate::core::protocol::contracts::has_writer(
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
selected,
) {
record_event_compatibility(
openlatch_dir,
None,
&format!("selected decision_event v{selected} has no local writer"),
);
return Err(CloudError::CompatibilityUnavailable);
}
if let Err(error) = crate::core::protocol::contracts::record_selection(
openlatch_dir,
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
selected,
) {
tracing::debug!(%error, "could not persist decision-event selection");
}
}
return Ok(());
}
if status.as_u16() == 409 {
let value = response.json::<serde_json::Value>().await.ok();
if value.as_ref().is_some_and(|problem| {
problem.get("type").and_then(serde_json::Value::as_str)
== Some(crate::core::protocol::contracts::COMPATIBILITY_PROBLEM_TYPE)
}) {
let platform_range = value
.as_ref()
.and_then(|problem| problem.get("platform_range"))
.and_then(event_problem_range);
record_event_compatibility(
openlatch_dir,
platform_range,
"no common retained representation",
);
return Err(CloudError::CompatibilityUnavailable);
}
return Err(CloudError::ClientError(409));
}
match status.as_u16() {
401 | 403 => Err(CloudError::AuthError),
429 => {
let retry_after_secs = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(config.rate_limit_default_secs);
Err(CloudError::RateLimit { retry_after_secs })
}
402 => Err(license_refused(&response.headers().clone(), response).await),
503 => {
let headers = response.headers().clone();
let body = response.text().await.unwrap_or_default();
if body_code_is_site_license(&body) {
Err(license_refused_from(&headers, &body))
} else {
Err(CloudError::ServerError)
}
}
500..=599 => Err(CloudError::ServerError),
code => Err(CloudError::ClientError(code)),
}
}
async fn license_refused(
headers: &reqwest::header::HeaderMap,
response: reqwest::Response,
) -> CloudError {
let body = response.text().await.unwrap_or_default();
license_refused_from(headers, &body)
}
pub(crate) fn license_refused_from(headers: &reqwest::header::HeaderMap, body: &str) -> CloudError {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let error = parsed.as_ref().and_then(|v| v.get("error"));
let code = error
.and_then(|e| e.get("code"))
.and_then(serde_json::Value::as_str)
.filter(|code| code.starts_with("license_") || SITE_LICENSE_CODES.contains(code))
.unwrap_or(LICENSE_UNKNOWN_CODE)
.to_string();
let licensing_url = error
.and_then(|e| {
e.pointer("/details/0/licensing_url")
.or_else(|| e.get("licensing_url"))
})
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let retry_after = headers
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after)
.map(|secs| Duration::from_secs(secs.min(MAX_RETRY_AFTER_SECS)));
CloudError::LicenseRefused {
code,
licensing_url,
retry_after,
}
}
async fn renegotiate_event_writer(
client: &reqwest::Client,
config: &CloudConfig,
key: &SecretString,
openlatch_dir: &Path,
egress: &crate::egress::EgressReporter,
) -> Result<u32, CloudError> {
let request_id = uuid::Uuid::now_v7().to_string();
let headers = build_cloud_headers(
key,
&request_id,
config.host_key.as_deref(),
config.agent_id.as_deref(),
);
let url = format!(
"{}/api/v1/events/ingest",
config.api_url.trim_end_matches('/')
);
let response = match client
.post(&url)
.headers(headers)
.header(
crate::core::protocol::contracts::SUPPORT_HEADER,
crate::core::protocol::contracts::support_header_value(),
)
.body("[]")
.send()
.await
{
Ok(response) => {
egress.record_ok(&url);
response
}
Err(error) => {
egress.record_failure(&url, &error);
return Err(CloudError::Network);
}
};
let status = response.status();
if status.is_success() {
let selected = response
.headers()
.get(crate::core::protocol::contracts::SELECTED_HEADER)
.and_then(|raw| raw.to_str().ok())
.and_then(|raw| crate::core::protocol::contracts::parse_selected(raw).ok())
.and_then(|map| {
map.get(crate::core::protocol::contracts::DECISION_EVENT_FAMILY)
.copied()
});
let Some(selected) = selected.filter(|version| {
crate::core::protocol::contracts::has_writer(
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
*version,
)
}) else {
record_event_compatibility(
openlatch_dir,
None,
"event renegotiation returned no usable decision_event acknowledgement",
);
return Err(CloudError::CompatibilityUnavailable);
};
if let Err(error) = crate::core::protocol::contracts::record_selection(
openlatch_dir,
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
selected,
) {
tracing::debug!(%error, "could not persist renegotiated decision-event selection");
}
return Ok(selected);
}
if status.as_u16() == 409 {
let value = response.json::<serde_json::Value>().await.ok();
if value.as_ref().is_some_and(|problem| {
problem.get("type").and_then(serde_json::Value::as_str)
== Some(crate::core::protocol::contracts::COMPATIBILITY_PROBLEM_TYPE)
}) {
let platform_range = value
.as_ref()
.and_then(|problem| problem.get("platform_range"))
.and_then(event_problem_range);
record_event_compatibility(
openlatch_dir,
platform_range,
"no common retained representation",
);
return Err(CloudError::CompatibilityUnavailable);
}
record_event_compatibility(
openlatch_dir,
None,
"event renegotiation returned an unregistered 409 response",
);
return Err(CloudError::CompatibilityUnavailable);
}
match status.as_u16() {
401 | 403 => Err(CloudError::AuthError),
402 => Err(license_refused(&response.headers().clone(), response).await),
429 => {
let retry_after_secs = response
.headers()
.get("Retry-After")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(config.rate_limit_default_secs);
Err(CloudError::RateLimit { retry_after_secs })
}
500..=599 => Err(CloudError::ServerError),
code => {
record_event_compatibility(
openlatch_dir,
None,
&format!("event renegotiation was refused with HTTP {code}"),
);
Err(CloudError::CompatibilityUnavailable)
}
}
}
fn event_problem_range(
value: &serde_json::Value,
) -> Option<crate::core::protocol::contracts::VersionRange> {
use crate::core::protocol::contracts::VersionRange;
if let Some(raw) = value.as_str() {
return crate::core::protocol::contracts::parse_ranges(&format!("peer={raw}"))
.ok()?
.get("peer")
.copied();
}
if let Some(values) = value.as_array() {
return VersionRange::new(
u32::try_from(values.first()?.as_u64()?).ok()?,
u32::try_from(values.get(1)?.as_u64()?).ok()?,
);
}
VersionRange::new(
u32::try_from(value.get("oldest")?.as_u64()?).ok()?,
u32::try_from(value.get("newest")?.as_u64()?).ok()?,
)
}
fn record_event_compatibility(
openlatch_dir: &Path,
platform_range: Option<crate::core::protocol::contracts::VersionRange>,
detail: &str,
) {
use crate::core::protocol::contracts::{self, CompatibilityDiagnostic, DECISION_EVENT_FAMILY};
let Some(client_range) = contracts::supported_ranges()
.get(DECISION_EVENT_FAMILY)
.copied()
else {
return;
};
let diagnostic = CompatibilityDiagnostic {
family: DECISION_EVENT_FAMILY.to_string(),
client_range,
platform_range,
last_selection: None,
observed_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
detail: detail.to_string(),
};
if let Err(error) = contracts::record_diagnostic(openlatch_dir, diagnostic) {
tracing::debug!(%error, "could not persist decision-event compatibility diagnostic");
}
}
pub fn persist_cloud_state(openlatch_dir: &Path, auth_error: bool) -> std::io::Result<()> {
std::fs::create_dir_all(openlatch_dir)?;
let updated_at = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let content = format!(
"{{\"auth_error\":{},\"updated_at\":\"{}\"}}\n",
auth_error, updated_at
);
let tmp_path = openlatch_dir.join("cloud_state.json.tmp");
let final_path = openlatch_dir.join("cloud_state.json");
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::supervision::task::{
spawn_supervised, Backoff, HealthRegistry, RestartPolicy, TaskSpec, TaskState,
};
use secrecy::SecretString;
use std::sync::atomic::Ordering;
use std::sync::Mutex;
use tokio::sync::mpsc;
fn no_sessions() -> Arc<SessionRegistry> {
Arc::new(SessionRegistry::default())
}
async fn wait_until<F: FnMut() -> bool>(mut cond: F, timeout: std::time::Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if cond() {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
struct TestCredentialProvider {
key: Mutex<Option<String>>,
retrievals: std::sync::atomic::AtomicU64,
invalidations: std::sync::atomic::AtomicU64,
}
impl TestCredentialProvider {
fn with_key(key: &str) -> Arc<Self> {
Arc::new(Self {
key: Mutex::new(Some(key.to_string())),
retrievals: std::sync::atomic::AtomicU64::new(0),
invalidations: std::sync::atomic::AtomicU64::new(0),
})
}
fn empty() -> Arc<Self> {
Arc::new(Self {
key: Mutex::new(None),
retrievals: std::sync::atomic::AtomicU64::new(0),
invalidations: std::sync::atomic::AtomicU64::new(0),
})
}
fn set_key(&self, key: &str) {
*self.key.lock().unwrap() = Some(key.to_string());
}
fn retrievals(&self) -> u64 {
self.retrievals.load(Ordering::Relaxed)
}
fn invalidations(&self) -> u64 {
self.invalidations.load(Ordering::Relaxed)
}
}
impl CredentialProvider for TestCredentialProvider {
fn retrieve(&self) -> Option<SecretString> {
self.retrievals.fetch_add(1, Ordering::Relaxed);
self.key
.lock()
.ok()
.and_then(|g| g.as_ref().map(|k| SecretString::from(k.clone())))
}
fn invalidate(&self) {
self.invalidations.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn test_update_emergency_mode_engages_after_sustained_streak() {
use std::time::{SystemTime, UNIX_EPOCH};
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
for _ in 0..(EMERGENCY_DROP_THRESHOLD + 1) {
state.record_live_drop();
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
state.live_drops_window_start.store(
now_ms.saturating_sub(EMERGENCY_WINDOW_MS + 1_000),
Ordering::Relaxed,
);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
state.is_emergency_mode(),
"detector must engage on sustained streak"
);
}
#[test]
fn test_update_emergency_mode_does_not_engage_on_brief_spike() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
for _ in 0..(EMERGENCY_DROP_THRESHOLD + 1) {
state.record_live_drop();
}
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
!state.is_emergency_mode(),
"detector must not engage on a brief spike (window too short)"
);
}
#[test]
fn test_update_emergency_mode_clears_after_two_clean_ticks() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
state.set_emergency_mode(true);
assert!(state.is_emergency_mode());
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(state.is_emergency_mode());
assert_eq!(recovery_ticks, 1);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(!state.is_emergency_mode());
}
#[test]
fn test_update_emergency_mode_engages_on_sustained_high_water() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
let now_ms = super::super::now_unix_ms();
state.channel_high_water_start_ms.store(
now_ms.saturating_sub(HIGH_WATER_WINDOW_MS + 1_000),
Ordering::Relaxed,
);
assert_eq!(state.consecutive_live_drops(), 0);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(
state.is_emergency_mode(),
"high-water sustained should engage emergency mode without live drops"
);
}
#[test]
fn test_update_emergency_mode_does_not_clear_while_high_water_active() {
let state = CloudState::new();
let config = CloudConfig::default();
let mut recovery_ticks = 0u32;
state.set_emergency_mode(true);
let now_ms = super::super::now_unix_ms();
state
.channel_high_water_start_ms
.store(now_ms.saturating_sub(500), Ordering::Relaxed);
update_emergency_mode(&state, &config, &mut recovery_ticks);
assert!(state.is_emergency_mode());
assert_eq!(
recovery_ticks, 0,
"recovery debounce must stay at 0 while high-water remains"
);
}
#[test]
fn test_build_cloud_client_creates_client_with_pool_max_idle_per_host() {
let config = CloudConfig::default();
let _client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
}
#[test]
fn build_cloud_client_reports_a_refused_route_instead_of_panicking() {
let mut egress = crate::egress::EgressConfig::direct();
egress.mode = crate::egress::ProxyMode::Auto;
egress.allow_direct = false;
let err = build_cloud_client(&CloudConfig::default(), &egress)
.expect_err("no proxy plus allow_direct = false must refuse");
assert_eq!(err.code, crate::error::ERR_DIRECT_FORBIDDEN);
}
#[tokio::test]
async fn test_worker_exits_cleanly_when_channel_closed() {
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
drop(tx);
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
crate::egress::EgressReporter::direct(),
state,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let result = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
assert!(
result.is_ok(),
"worker must exit when channel is closed (timed out waiting)"
);
}
#[tokio::test]
async fn test_worker_skips_posts_when_no_credential_available() {
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::empty();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
crate::egress::EgressReporter::direct(),
state,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
drop(tx);
let result = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
assert!(result.is_ok(), "worker must exit cleanly");
}
#[test]
fn test_persist_cloud_state_writes_valid_json_with_auth_error_true() {
let dir = tempfile::tempdir().unwrap();
persist_cloud_state(dir.path(), true).expect("persist must succeed");
let content = std::fs::read_to_string(dir.path().join("cloud_state.json"))
.expect("cloud_state.json must exist");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("must be valid JSON");
assert_eq!(
parsed["auth_error"], true,
"auth_error must be true: {content}"
);
assert!(
parsed["updated_at"].as_str().is_some(),
"updated_at must be present: {content}"
);
}
#[test]
fn test_persist_cloud_state_writes_valid_json_with_auth_error_false() {
let dir = tempfile::tempdir().unwrap();
persist_cloud_state(dir.path(), false).expect("persist must succeed");
let content = std::fs::read_to_string(dir.path().join("cloud_state.json"))
.expect("cloud_state.json must exist");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("must be valid JSON");
assert_eq!(parsed["auth_error"], false);
}
#[test]
fn test_persist_cloud_state_creates_parent_directory_if_missing() {
let base = tempfile::tempdir().unwrap();
let nested = base.path().join("a").join("b").join("c");
assert!(!nested.exists());
persist_cloud_state(&nested, false).expect("must create directories and write");
assert!(nested.join("cloud_state.json").exists());
}
const LICENSING_URL: &str = "https://app.openlatch.ai/settings/licensing";
const I2_LICENSE_BODY: &str = concat!(
r#"{"error":{"code":"license_expired","message":"This organization's licence has expired.","#,
r#""details":[{"licensing_url":"https://app.openlatch.ai/settings/licensing"}]}}"#
);
fn license_config(server: &mockito::Server) -> CloudConfig {
CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
retry_delay_ms: 5,
..Default::default()
}
}
fn license_worker(
server: &mockito::Server,
dir: &tempfile::TempDir,
outbox: Option<Arc<Outbox>>,
) -> (
mpsc::Sender<CloudEvent>,
CloudState,
tokio::task::JoinHandle<()>,
) {
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let state = CloudState::new();
let config = license_config(server);
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
outbox,
None,
SourceFormats::new(),
no_sessions(),
));
(tx, state, handle)
}
fn spawn_license_drain(
server: &mockito::Server,
dir: &tempfile::TempDir,
outbox: Arc<Outbox>,
state: CloudState,
) -> (
tokio::task::JoinHandle<()>,
tokio::sync::watch::Sender<bool>,
) {
let config = license_config(server);
let drain_client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let handle = tokio::spawn(run_outbox_drain_on(
outbox,
drain_client,
config,
TestCredentialProvider::with_key("test-api-key"),
state,
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
crate::egress::EgressReporter::direct(),
shutdown_rx,
None,
));
(handle, shutdown_tx)
}
async fn send_event(tx: &mpsc::Sender<CloudEvent>, id: &str) {
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({ "id": id }),
agent_id: "agt_test".to_string(),
})
.await;
}
#[tokio::test]
async fn a_402_retains_the_batch_and_is_never_an_auth_error() {
use std::sync::atomic::Ordering;
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_header("Retry-After", "900")
.with_body(I2_LICENSE_BODY)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let (tx, state, handle) = license_worker(&server, &dir, Some(outbox.clone()));
send_event(&tx, "evt_licensed").await;
tokio::time::sleep(Duration::from_millis(250)).await;
assert_eq!(outbox.pending_count(), 1, "the event must be retained");
assert_eq!(
state.drop_count(),
0,
"a retained event is not a drop — the outbox has it"
);
assert!(
!state.auth_error.load(Ordering::Relaxed),
"a licence refusal is never a credential failure"
);
let state_path = dir.path().join("cloud_state.json");
if state_path.exists() {
let persisted: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
persisted["auth_error"], false,
"a licence refusal must not persist an auth error"
);
}
let gate = state.license_gate().expect("the gate must be set");
assert_eq!(gate.code, "license_expired");
assert_eq!(gate.licensing_url.as_deref(), Some(LICENSING_URL));
let wait = gate
.until
.saturating_duration_since(std::time::Instant::now());
assert!(
wait > Duration::from_secs(880) && wait <= Duration::from_secs(900),
"the gate must honour Retry-After: 900, got {wait:?}"
);
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn a_standing_gate_stops_the_worker_posting_at_all() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_header("Retry-After", "900")
.with_body(I2_LICENSE_BODY)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let (tx, state, handle) = license_worker(&server, &dir, Some(outbox.clone()));
for id in ["evt_1", "evt_2", "evt_3"] {
send_event(&tx, id).await;
tokio::time::sleep(Duration::from_millis(120)).await;
}
assert!(state.license_gate_active());
assert_eq!(
outbox.pending_count(),
3,
"every event is queued, not just the one that met the refusal"
);
mock.assert_async().await;
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn with_no_outbox_a_refusal_counts_drops() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_header("Retry-After", "900")
.with_body(I2_LICENSE_BODY)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let (tx, state, handle) = license_worker(&server, &dir, None);
send_event(&tx, "evt_a").await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(state.drop_count(), 1);
send_event(&tx, "evt_b").await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(state.drop_count(), 2, "a gated flush drops too");
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn a_500_then_a_402_on_the_retry_is_gated_not_dropped() {
let mut server = mockito::Server::new_async().await;
let _first = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("boom")
.expect(1)
.create_async()
.await;
let _second = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_body(I2_LICENSE_BODY)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let (tx, state, handle) = license_worker(&server, &dir, Some(outbox.clone()));
send_event(&tx, "evt_retry").await;
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(state.license_gate().is_some(), "the retry's refusal gates");
assert_eq!(outbox.pending_count(), 1);
assert_eq!(state.drop_count(), 0);
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn a_drain_that_meets_a_refusal_keeps_its_entries_and_charges_nothing() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_header("Retry-After", "900")
.with_body(I2_LICENSE_BODY)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
outbox
.append(&serde_json::json!({"id": "evt_queued"}))
.unwrap();
let (tx, state, handle) = license_worker(&server, &dir, Some(outbox.clone()));
let (drain_handle, _drain_shutdown) =
spawn_license_drain(&server, &dir, outbox.clone(), state.clone());
for _ in 0..5 {
state.notify_drain();
tokio::time::sleep(Duration::from_millis(80)).await;
}
mock.assert_async().await;
assert!(state.license_gate_active());
assert_eq!(
outbox.pending_count(),
1,
"the entry is retained, never quarantined"
);
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
drain_handle.abort();
}
#[tokio::test]
async fn a_successful_drain_clears_the_gate() {
let mut server = mockito::Server::new_async().await;
let _health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.with_body("{}")
.create_async()
.await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body("{}")
.expect_at_least(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
outbox
.append(&serde_json::json!({"id": "evt_backlog"}))
.unwrap();
let (tx, state, handle) = license_worker(&server, &dir, Some(outbox.clone()));
state.set_license_gate("license_expired".to_string(), None, Some(Duration::ZERO));
assert!(state.license_gate().is_some());
let (drain_handle, _drain_shutdown) =
spawn_license_drain(&server, &dir, outbox.clone(), state.clone());
for _ in 0..10 {
if outbox.pending_count() == 0 && state.license_gate().is_none() {
break;
}
state.notify_drain();
tokio::time::sleep(Duration::from_millis(80)).await;
}
mock.assert_async().await;
assert_eq!(outbox.pending_count(), 0, "the backlog replayed");
assert!(
state.license_gate().is_none(),
"a successful POST is what proves the refusal is over"
);
drop(tx);
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
drain_handle.abort();
}
#[tokio::test]
async fn test_worker_auth_error_set_when_credential_available_but_server_returns_401() {
use std::sync::atomic::Ordering;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(401)
.with_body("{}")
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let provider_handle = provider.clone();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state_clone,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_test"}),
agent_id: "agt_test".to_string(),
})
.await;
assert!(
wait_until(
|| state.auth_error.load(Ordering::Relaxed),
std::time::Duration::from_secs(5),
)
.await,
"auth_error must be true after 401 response"
);
assert_eq!(
provider_handle.invalidations(),
1,
"the 401 latch must invalidate the credential provider exactly once"
);
let state_path = dir.path().join("cloud_state.json");
assert!(state_path.exists(), "cloud_state.json must be written");
let content = std::fs::read_to_string(&state_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["auth_error"], true);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock.assert_async().await;
}
#[tokio::test]
async fn worker_startup_clears_a_stale_auth_error_left_by_a_dead_daemon() {
let dir = tempfile::tempdir().unwrap();
let state_path = dir.path().join("cloud_state.json");
persist_cloud_state(dir.path(), true).unwrap();
let stale: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
stale["auth_error"], true,
"precondition: stale latch on disk"
);
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
CloudConfig::default(),
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let cleared = wait_until(
|| {
std::fs::read_to_string(&state_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.is_some_and(|v| v["auth_error"] == false)
},
std::time::Duration::from_secs(5),
)
.await;
assert!(
cleared,
"a freshly started worker must publish its own state, not inherit \
the previous daemon's latch"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
}
#[tokio::test]
async fn test_worker_retries_once_on_5xx_then_drops() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("{}")
.expect(2) .create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
retry_delay_ms: 10, ..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock.assert_async().await;
}
#[tokio::test]
async fn test_worker_honors_retry_after_header_on_429() {
let mut server = mockito::Server::new_async().await;
let mock_429 = server
.mock("POST", "/api/v1/events/ingest")
.with_status(429)
.with_header("Retry-After", "1")
.with_body("{}")
.expect(1)
.create_async()
.await;
let mock_200 = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body("{\"status\":\"accepted\"}")
.expect(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_429.assert_async().await;
mock_200.assert_async().await;
}
#[tokio::test]
async fn test_health_tick_clears_consecutive_drops_on_2xx() {
let mut server = mockito::Server::new_async().await;
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.with_body(r#"{"status":"ok"}"#)
.expect_at_least(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
state.record_drop();
assert_eq!(state.consecutive_drops(), 3);
assert_eq!(state.drop_count(), 3);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state_clone,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
assert!(
wait_until(
|| state.consecutive_drops() == 0,
std::time::Duration::from_secs(5),
)
.await,
"health tick must clear consecutive_drops on 2xx"
);
assert_eq!(
state.drop_count(),
3,
"lifetime drop_count must be preserved"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_health.assert_async().await;
}
#[tokio::test]
async fn test_health_tick_does_not_clear_on_5xx() {
let mut server = mockito::Server::new_async().await;
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.expect_at_least(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state_clone,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
assert!(
wait_until(
|| state.consecutive_probe_failures() >= 1,
std::time::Duration::from_secs(5),
)
.await,
"at least one health probe failure must be recorded"
);
assert_eq!(
state.consecutive_drops(),
2,
"failed health probe must not reset consecutive_drops"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_health.assert_async().await;
}
#[tokio::test]
async fn a_client_swapped_into_the_handle_is_used_by_the_next_flush() {
let mut server = mockito::Server::new_async().await;
let mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect_at_least(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 1_000_000));
let clients = crate::egress::EgressClients::new(
crate::egress::Timeouts::default(),
crate::egress::Timeouts::default(),
);
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let cloud_state = CloudState::new();
let worker = {
let mut rx = rx;
let config = config.clone();
let cloud_state = cloud_state.clone();
let dir = dir.path().to_path_buf();
let outbox = outbox.clone();
let handle = clients.cloud.clone();
tokio::spawn(async move {
run_cloud_worker_on(
&mut rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
cloud_state,
dir,
Some(outbox),
None,
handle,
SourceFormats::new(),
no_sessions(),
)
.await
})
};
let send = |n: &str| CloudEvent {
envelope: serde_json::json!({ "id": n }),
agent_id: "agt_test".to_string(),
};
let _ = tx.send(send("evt_no_route")).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
cloud_state.forwarded_count(),
0,
"with no permitted route the batch must be spooled, never sent"
);
clients
.apply(&crate::egress::EgressConfig::direct())
.expect("install a route");
let _ = tx.send(send("evt_healed")).await;
for _ in 0..40 {
if cloud_state.forwarded_count() > 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert!(
cloud_state.forwarded_count() >= 1,
"the next flush must use the newly installed client"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), worker).await;
mock_ingest.assert_async().await;
}
#[tokio::test]
async fn test_successful_post_clears_consecutive_drops() {
let mut server = mockito::Server::new_async().await;
let _mock_health = server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.create_async()
.await;
let mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let state = CloudState::new();
state.record_drop();
state.record_drop();
state.record_drop();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
..Default::default()
};
let state_clone = state.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state_clone,
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_test"}),
agent_id: "agt_test".to_string(),
})
.await;
assert!(
wait_until(
|| state.forwarded_count() == 1,
std::time::Duration::from_secs(5),
)
.await,
"event must be forwarded"
);
assert_eq!(
state.consecutive_drops(),
0,
"successful forward must clear consecutive_drops"
);
assert_eq!(state.drop_count(), 3, "lifetime drop_count preserved");
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_ingest.assert_async().await;
}
#[tokio::test]
async fn test_worker_recovers_when_credential_appears_after_startup() {
let mut server = mockito::Server::new_async().await;
let mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
const CHANNEL_CAPACITY: usize = 10;
let (tx, rx) = mpsc::channel::<CloudEvent>(CHANNEL_CAPACITY);
let provider = TestCredentialProvider::empty();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
credential_poll_interval_ms: 50,
..Default::default()
};
let provider_handle = provider.clone();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..3 {
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i}")}),
agent_id: "agt_test".to_string(),
})
.await;
}
let consumed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
while tx.capacity() < CHANNEL_CAPACITY {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await;
assert!(
consumed.is_ok(),
"worker never drained the three credential-less events ({} slots still queued)",
CHANNEL_CAPACITY - tx.capacity()
);
assert_eq!(
state.forwarded_count(),
0,
"events must not be forwarded before a credential is available"
);
provider_handle.set_key("late-arriving-key");
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_after_reload"}),
agent_id: "agt_test".to_string(),
})
.await;
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), handle).await;
mock_ingest.assert_async().await;
assert_eq!(
state.forwarded_count(),
1,
"exactly one event should be forwarded after credential hot-reload"
);
}
#[tokio::test]
async fn test_outbox_quarantines_on_persistent_5xx() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _mock_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("boom")
.expect_at_least(2)
.create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let drain_provider = provider.clone();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
retry_delay_ms: 5,
..Default::default()
};
outbox
.append(&serde_json::json!({"id": "evt_poison"}))
.unwrap();
assert_eq!(outbox.pending_count(), 1);
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config.clone(),
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
SourceFormats::new(),
no_sessions(),
));
let drain_client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let (_drain_shutdown_tx, drain_shutdown_rx) = tokio::sync::watch::channel(false);
let drain_handle = tokio::spawn(run_outbox_drain_on(
outbox.clone(),
drain_client,
config,
drain_provider,
state.clone(),
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
crate::egress::EgressReporter::direct(),
drain_shutdown_rx,
None,
));
for _ in 0..(OUTBOX_MAX_ATTEMPTS as usize + 4) {
state.notify_drain();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
drain_handle.abort();
assert_eq!(
outbox.pending_count(),
0,
"quarantine must drain the outbox past the poison entry"
);
}
#[tokio::test]
async fn test_outbox_spools_on_failure_and_drains_on_recovery() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let mock_fail_ingest = server
.mock("POST", "/api/v1/events/ingest")
.with_status(500)
.with_body("boom")
.expect(2) .create_async()
.await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let provider = TestCredentialProvider::with_key("test-api-key");
let drain_provider = provider.clone();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
batch_max_wait_ms: 50,
retry_delay_ms: 10,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config.clone(),
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
SourceFormats::new(),
no_sessions(),
));
let drain_client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let (_drain_shutdown_tx, drain_shutdown_rx) = tokio::sync::watch::channel(false);
let drain_handle = tokio::spawn(run_outbox_drain_on(
outbox.clone(),
drain_client,
config,
drain_provider,
state.clone(),
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
crate::egress::EgressReporter::direct(),
drain_shutdown_rx,
None,
));
let _ = tx
.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_offline_1"}),
agent_id: "agt_test".to_string(),
})
.await;
assert!(
wait_until(
|| outbox.pending_count() == 1,
std::time::Duration::from_secs(5),
)
.await,
"envelope must be spooled after the failed retries"
);
mock_fail_ingest.assert_async().await;
assert_eq!(state.forwarded_count(), 0);
server.reset();
let mock_health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.expect_at_least(1)
.create_async()
.await;
let mock_replay = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_body(r#"{"status":"accepted"}"#)
.expect(1)
.create_async()
.await;
assert!(
wait_until(|| mock_replay.matched(), std::time::Duration::from_secs(5)).await,
"the drain must replay the spooled event after recovery"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
drain_handle.abort();
mock_health.assert_async().await;
mock_replay.assert_async().await;
assert_eq!(
outbox.pending_count(),
0,
"outbox must be drained after cloud recovery"
);
assert!(
state.forwarded_count() >= 1,
"forwarded_count must reflect the replayed event, got {}",
state.forwarded_count()
);
}
type Captured = Arc<Mutex<Vec<String>>>;
async fn capture_ingest(
server: &mut mockito::ServerGuard,
status: usize,
) -> (mockito::Mock, Captured) {
let captured: Captured = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(status)
.with_body_from_request(move |req| {
let body = req
.utf8_lossy_body()
.map(|b| b.to_string())
.unwrap_or_default();
sink.lock().unwrap().push(body);
br#"{"status":"accepted"}"#.to_vec()
})
.expect_at_least(0)
.create_async()
.await;
(mock, captured)
}
async fn quiet_health(server: &mut mockito::ServerGuard) -> mockito::Mock {
server
.mock("GET", "/api/v1/health")
.with_status(500)
.with_body("{}")
.expect_at_least(0)
.create_async()
.await
}
fn evt(id: &str) -> CloudEvent {
CloudEvent {
envelope: serde_json::json!({"id": id, "specversion": "1.0"}),
agent_id: "agt_test".to_string(),
}
}
fn batch_sizes(captured: &Captured) -> Vec<usize> {
captured
.lock()
.unwrap()
.iter()
.map(|body| {
serde_json::from_str::<Vec<serde_json::Value>>(body)
.expect("every request body must be a JSON array")
.len()
})
.collect()
}
fn captured_ids(captured: &Captured) -> std::collections::BTreeSet<String> {
captured
.lock()
.unwrap()
.iter()
.flat_map(|body| {
serde_json::from_str::<Vec<serde_json::Value>>(body)
.expect("every request body must be a JSON array")
.into_iter()
.map(|v| v["id"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>()
})
.collect()
}
#[tokio::test]
async fn exact_request_count_for_n_events() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(200);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..120 {
tx.send(evt(&format!("evt_{i:03}"))).await.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
assert_eq!(
batch_sizes(&captured),
vec![50, 50, 20],
"120 events at batch_max_events=50 must be exactly 3 requests"
);
assert_eq!(state.forwarded_count(), 120);
}
#[tokio::test]
async fn flushes_on_time_when_under_size() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 150,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..3 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
assert_eq!(
batch_sizes(&captured),
vec![3],
"3 events must leave as one time-triggered request"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn deadline_anchored_to_first_item() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 1300,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
tx.send(evt("evt_first")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
tx.send(evt("evt_second")).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
assert_eq!(
batch_sizes(&captured),
vec![2],
"the batch must flush 1000ms after the FIRST event, not after the last"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn no_request_exceeds_caps() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(400);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 100,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let padding = "x".repeat(4096);
for i in 0..300 {
tx.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i:03}"), "data": padding}),
agent_id: "agt_test".to_string(),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), handle).await;
let bodies = captured.lock().unwrap().clone();
assert!(!bodies.is_empty(), "events must have been forwarded");
let mut total = 0usize;
let mut byte_capped = false;
for body in &bodies {
assert!(
body.len() <= MAX_BATCH_BYTES,
"request body of {} bytes exceeds the 256KB cap",
body.len()
);
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
assert!(
elements.len() <= MAX_BATCH_EVENTS,
"request carried {} events, over the 100-event cap",
elements.len()
);
if elements.len() < 100 {
byte_capped = true;
}
total += elements.len();
}
assert_eq!(total, 300, "every event must still be delivered");
assert!(
byte_capped,
"4KB payloads must close batches on the byte cap before the 100-event cap"
);
}
#[tokio::test]
async fn oversized_single_event_dropped() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 100,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
tx.send(CloudEvent {
envelope: serde_json::json!({"id": "evt_huge", "data": "x".repeat(300_000)}),
agent_id: "agt_test".to_string(),
})
.await
.unwrap();
tx.send(evt("evt_ok_1")).await.unwrap();
tx.send(evt("evt_ok_2")).await.unwrap();
assert!(
wait_until(
|| state.forwarded_count() == 2,
std::time::Duration::from_secs(5),
)
.await,
"the two normal events must be forwarded"
);
assert_eq!(
batch_sizes(&captured),
vec![2],
"only the two normal events may be posted — the queue must not wedge"
);
assert_eq!(
state.drop_count(),
1,
"the oversized event counts as a drop"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn failed_batch_spools_every_event() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 500).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 100,
retry_delay_ms: 10,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..7 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
tokio::time::sleep(std::time::Duration::from_millis(600)).await;
assert_eq!(
batch_sizes(&captured),
vec![7, 7],
"one batch attempt plus exactly one retry"
);
let body = std::fs::read_to_string(outbox.path()).expect("outbox.jsonl must exist");
assert_eq!(
body.lines().count(),
7,
"a failed batch of 7 must spool 7 LINES, not one batch line: {body}"
);
for line in body.lines() {
serde_json::from_str::<serde_json::Value>(line)
.expect("every spooled line must be a standalone envelope");
}
assert_eq!(state.drop_count(), 7, "a spooled batch of 7 is 7 drops");
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn outbox_drain_rebatches() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(10);
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
for i in 0..120 {
outbox
.append(&serde_json::json!({"id": format!("evt_{i:03}")}))
.unwrap();
}
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let provider = TestCredentialProvider::with_key("test-api-key");
let drain_provider = provider.clone();
let state = CloudState::new();
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config.clone(),
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
Some(outbox.clone()),
None,
SourceFormats::new(),
no_sessions(),
));
let drain_client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let (_drain_shutdown_tx, drain_shutdown_rx) = tokio::sync::watch::channel(false);
let drain_handle = tokio::spawn(run_outbox_drain_on(
outbox.clone(),
drain_client,
config,
drain_provider,
state,
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
crate::egress::EgressReporter::direct(),
drain_shutdown_rx,
None,
));
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
assert_eq!(
batch_sizes(&captured),
vec![50, 50, 20],
"120 outbox entries must replay as 3 requests"
);
assert_eq!(outbox.pending_count(), 0, "the outbox must be empty");
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
drain_handle.abort();
}
#[tokio::test]
async fn outbox_drain_panic_is_observed_and_recovers() {
use crate::core::cloud::outbox::Outbox;
let mut server = mockito::Server::new_async().await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let cloud_state = CloudState::new();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let egress = crate::egress::EgressReporter::direct();
let client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let provider: Arc<dyn CredentialProvider> =
TestCredentialProvider::with_key("test-api-key");
let registry = Arc::new(HealthRegistry::new());
let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let fail_once = Arc::new(std::sync::atomic::AtomicBool::new(true));
let outbox_for_task = outbox.clone();
let cloud_state_for_task = cloud_state.clone();
let task_shutdown_rx = shutdown_rx.clone();
spawn_supervised(
®istry,
TaskSpec::new("outbox-drain-test", RestartPolicy::Always).with_backoff(Backoff::new(
Duration::from_millis(5),
Duration::from_millis(20),
)),
shutdown_rx,
move || {
run_outbox_drain_on(
outbox_for_task.clone(),
client.clone(),
config.clone(),
provider.clone(),
cloud_state_for_task.clone(),
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
egress.clone(),
task_shutdown_rx.clone(),
Some(fail_once.clone()),
)
},
);
tokio::time::sleep(Duration::from_millis(200)).await;
let health = registry.tasks()[0].clone();
assert!(
health.restarts() >= 1,
"a panicking outbox-drain pass must be recorded as a restart on the \
health registry — on main this task had no health entry at all, got {}",
health.restarts()
);
outbox
.append(&serde_json::json!({"id": "evt_after_recovery"}))
.unwrap();
cloud_state.notify_drain();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
outbox.pending_count(),
0,
"the outbox must drain after the drain task recovers from its panic"
);
assert!(
!batch_sizes(&captured).is_empty(),
"a replay POST must go out after recovery"
);
}
#[tokio::test]
async fn outbox_drain_stops_cleanly_on_shutdown() {
use crate::core::cloud::outbox::Outbox;
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let cloud_state = CloudState::new();
let config = CloudConfig::default();
let egress = crate::egress::EgressReporter::direct();
let client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let provider: Arc<dyn CredentialProvider> =
TestCredentialProvider::with_key("test-api-key");
let registry = Arc::new(HealthRegistry::new());
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let task_shutdown_rx = shutdown_rx.clone();
let handle = spawn_supervised(
®istry,
TaskSpec::new("outbox-drain-shutdown-test", RestartPolicy::Always).with_backoff(
Backoff::new(Duration::from_millis(5), Duration::from_millis(20)),
),
shutdown_rx,
move || {
run_outbox_drain_on(
outbox.clone(),
client.clone(),
config.clone(),
provider.clone(),
cloud_state.clone(),
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
egress.clone(),
task_shutdown_rx.clone(),
None,
)
},
);
tokio::time::sleep(Duration::from_millis(100)).await;
shutdown_tx.send(true).expect("shutdown send");
let joined = tokio::time::timeout(Duration::from_millis(500), handle).await;
assert!(
joined.is_ok(),
"the drain task must stop within 500ms of shutdown, not be force-aborted \
after the supervisor's drain window"
);
assert_eq!(registry.tasks()[0].state(), TaskState::Stopped);
}
#[tokio::test]
async fn outbox_drain_shutdown_wins_a_race_against_notify() {
use crate::core::cloud::outbox::Outbox;
for trial in 0..20 {
let dir = tempfile::tempdir().unwrap();
let outbox = Arc::new(Outbox::new(dir.path(), 0));
let cloud_state = CloudState::new();
let config = CloudConfig::default();
let egress = crate::egress::EgressReporter::direct();
let client = crate::egress::ClientHandle::of(
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap(),
);
let provider = TestCredentialProvider::with_key("test-api-key");
let provider_for_task = provider.clone();
let cloud_state_for_task = cloud_state.clone();
let registry = Arc::new(HealthRegistry::new());
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let task_shutdown_rx = shutdown_rx.clone();
let handle = spawn_supervised(
®istry,
TaskSpec::new("outbox-drain-race-test", RestartPolicy::Always).with_backoff(
Backoff::new(Duration::from_millis(5), Duration::from_millis(20)),
),
shutdown_rx,
move || {
run_outbox_drain_on(
outbox.clone(),
client.clone(),
config.clone(),
provider_for_task.clone(),
cloud_state_for_task.clone(),
dir.path().to_path_buf(),
SourceFormats::new(),
no_sessions(),
egress.clone(),
task_shutdown_rx.clone(),
None,
)
},
);
tokio::time::sleep(Duration::from_millis(100)).await;
cloud_state.notify_drain();
shutdown_tx.send(true).expect("shutdown send");
let joined = tokio::time::timeout(Duration::from_millis(500), handle).await;
assert!(
joined.is_ok(),
"trial {trial}: drain task must still stop promptly under the race"
);
assert_eq!(
provider.retrievals(),
1,
"trial {trial}: shutdown racing an already-stored notify must not start \
an extra drain pass — only the unconditional startup pass should have run"
);
}
}
#[tokio::test]
async fn shutdown_flushes_in_flight() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
Some(shutdown_rx),
SourceFormats::new(),
no_sessions(),
));
for i in 0..7 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
assert!(
captured.lock().unwrap().is_empty(),
"the batch must still be in flight — neither trigger has fired"
);
shutdown_tx.send(true).unwrap();
let exited = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await;
assert!(exited.is_ok(), "worker must exit on the shutdown signal");
assert_eq!(
batch_sizes(&captured),
vec![7],
"all 7 buffered events must flush at shutdown — zero lost"
);
assert_eq!(state.forwarded_count(), 7);
drop(tx);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_drain_is_bounded_against_a_refilling_sender() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
const PRE_SHUTDOWN: usize = 7;
let (tx, rx) = mpsc::channel::<CloudEvent>(PRE_SHUTDOWN);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
Some(shutdown_rx),
SourceFormats::new(),
no_sessions(),
));
for i in 0..PRE_SHUTDOWN {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
let refill_tx = tx.clone();
let refill = std::thread::spawn(move || {
let mut sent = 0u64;
let mut i = 0u64;
loop {
match refill_tx.try_send(evt(&format!("post_{i}"))) {
Ok(()) => {
sent += 1;
i += 1;
}
Err(mpsc::error::TrySendError::Full(_)) => {
std::thread::yield_now();
}
Err(mpsc::error::TrySendError::Closed(_)) => break,
}
}
sent
});
shutdown_tx.send(true).unwrap();
let exited = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
assert!(
exited.is_ok(),
"worker must exit within the bound despite a refilling sender"
);
drop(tx);
let refill_sent = refill.join().expect("refill thread must not panic");
assert_eq!(
captured_ids(&captured),
(0..PRE_SHUTDOWN)
.map(|i| format!("evt_{i}"))
.collect::<std::collections::BTreeSet<_>>(),
"the forwarded ids must be exactly the pre-shutdown burst, none of the {refill_sent} refill sends"
);
assert_eq!(state.forwarded_count(), PRE_SHUTDOWN as u64);
}
#[tokio::test]
async fn select_loop_loses_nothing() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(100);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 25,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..1000u32 {
tx.send(evt(&format!("evt_{i:04}"))).await.unwrap();
match i % 7 {
0 => tokio::time::sleep(std::time::Duration::from_millis(1)).await,
3 => tokio::task::yield_now().await,
_ => {}
}
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), handle).await;
let delivered: usize = batch_sizes(&captured).iter().sum();
assert_eq!(delivered, 1000, "every enqueued event must be delivered");
assert_eq!(state.forwarded_count(), 1000);
}
#[tokio::test]
async fn agentid_stamped_on_every_envelope() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 3,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..9 {
tx.send(CloudEvent {
envelope: serde_json::json!({"id": format!("evt_{i}")}),
agent_id: format!("agt_{i}"),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
let bodies = captured.lock().unwrap().clone();
assert_eq!(
bodies.len(),
3,
"9 events at batch_max_events=3 is 3 requests"
);
let mut seen = 0;
for body in &bodies {
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
for element in elements {
let id = element["id"].as_str().unwrap();
let n = id.trim_start_matches("evt_");
assert_eq!(
element["agentid"].as_str(),
Some(format!("agt_{n}").as_str()),
"every envelope must carry its own agentid: {element}"
);
seen += 1;
}
}
assert_eq!(seen, 9);
}
#[test]
fn clientversion_stamped_when_producer_omitted_it() {
let event = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_model_relay",
"source": "claude-code",
"type": "ai.openlatch.economics.usage",
"datacontenttype": "application/json",
"data": {"tokens": 42},
}),
agent_id: "agt_test".to_string(),
};
let stamped = stamp_extensions(
&event,
&SourceFormats::new(),
&SessionRegistry::default(),
None,
);
assert_eq!(
stamped["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"an envelope that reaches egress without a version must leave with one"
);
assert_eq!(stamped["agentid"].as_str(), Some("agt_test"));
assert_eq!(stamped["data"]["tokens"], 42);
assert_eq!(stamped["type"], "ai.openlatch.economics.usage");
}
#[test]
fn wireformat_is_stamped_on_a_hook_event() {
let formats: SourceFormats = [
("claude-code", WireFormat::AnthropicMessages),
("codex-cli", WireFormat::OpenAiResponses),
]
.into_iter()
.collect();
let event = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_hook",
"source": "codex-cli",
"type": "ai.openlatch.hook.pre_tool_use",
"data": {"tool_name": "Bash"},
}),
agent_id: "agt_test".to_string(),
};
let stamped = stamp_extensions(&event, &formats, &SessionRegistry::default(), None);
assert_eq!(
stamped["wireformat"].as_str(),
Some("openai-responses"),
"the agent's format must ride a HOOK event, not only an economics one"
);
assert_eq!(stamped["agentid"].as_str(), Some("agt_test"));
assert_eq!(stamped["data"]["tool_name"], "Bash");
}
#[test]
fn a_producer_that_named_the_format_keeps_it() {
let formats = SourceFormats::new();
let relay_row = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_econ",
"source": "cline",
"type": "ai.openlatch.economics.usage",
"wireformat": "google-generate-content",
"data": {"gen_ai.provider.name": "google"},
}),
agent_id: "agt_test".to_string(),
};
assert_eq!(
stamp_extensions(&relay_row, &formats, &SessionRegistry::default(), None)["wireformat"]
.as_str(),
Some("google-generate-content"),
"the listener served the request and knows which protocol it spoke"
);
let hook_event = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_hook",
"source": "cline",
"type": "pre_tool_use",
"data": {"toolName": "execute_command"},
}),
agent_id: "agt_test".to_string(),
};
assert_eq!(
stamp_extensions(&hook_event, &formats, &SessionRegistry::default(), None)
["wireformat"]
.as_str(),
Some("unknown"),
"always present, and never a value nobody observed"
);
let blank = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0", "id": "evt_blank", "source": "claude-code",
"type": "pre_tool_use", "wireformat": "",
}),
agent_id: "agt_test".to_string(),
};
let formats: SourceFormats = [("claude-code", WireFormat::AnthropicMessages)]
.into_iter()
.collect();
assert_eq!(
stamp_extensions(&blank, &formats, &SessionRegistry::default(), None)["wireformat"]
.as_str(),
Some("anthropic-messages")
);
}
#[test]
fn source_formats_invalidated_on_provider_change() {
let formats: SourceFormats = [("cline", WireFormat::OpenAiChatCompletions)]
.into_iter()
.collect();
let worker_view = formats.clone();
let event = CloudEvent {
envelope: serde_json::json!({
"specversion": "1.0",
"id": "evt_hook",
"source": "cline",
"type": "ai.openlatch.hook.pre_tool_use",
"data": {"tool_name": "Bash"},
}),
agent_id: "agt_test".to_string(),
};
assert_eq!(
stamp_extensions(&event, &worker_view, &SessionRegistry::default(), None)["wireformat"]
.as_str(),
Some("openai-chat-completions")
);
formats.set("cline", WireFormat::GoogleGenerateContent);
assert_eq!(
stamp_extensions(&event, &worker_view, &SessionRegistry::default(), None)["wireformat"]
.as_str(),
Some("google-generate-content"),
"the worker's view must follow, with no restart — a stale label here is \
mis-attributed economics for every turn until the daemon is bounced"
);
}
#[test]
fn wireformat_is_unknown_for_an_unknown_source() {
let formats: SourceFormats = [("claude-code", WireFormat::AnthropicMessages)]
.into_iter()
.collect();
for envelope in [
serde_json::json!({"id": "evt_a", "source": "unknown"}),
serde_json::json!({"id": "evt_b", "source": "codex-cli"}),
serde_json::json!({"id": "evt_c"}),
] {
let stamped = stamp_extensions(
&CloudEvent {
envelope: envelope.clone(),
agent_id: "agt_test".to_string(),
},
&formats,
&SessionRegistry::default(),
None,
);
assert!(
stamped.get("wireformat").is_some(),
"the key is never omitted: {envelope}"
);
assert_eq!(
stamped["wireformat"].as_str(),
Some("unknown"),
"{envelope}"
);
}
}
#[test]
fn clientversion_overwrites_a_stale_producer_value() {
let event = CloudEvent {
envelope: serde_json::json!({
"id": "evt_stale_hook",
"clientversion": "0.0.1-stale",
}),
agent_id: "agt_test".to_string(),
};
let stamped = stamp_extensions(
&event,
&SourceFormats::new(),
&SessionRegistry::default(),
None,
);
assert_eq!(
stamped["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"the forwarder's version must win over the emitter's"
);
}
const HOST: &str = "9262b296baa37a205734509345581251";
#[test]
fn a_producerless_envelope_leaves_the_egress_carrying_the_host_id() {
let stamped = stamp_extensions(
&config_event(),
&SourceFormats::new(),
&SessionRegistry::default(),
Some(HOST),
);
assert_eq!(stamped["hostid"].as_str(), Some(HOST));
}
#[test]
fn an_envelope_that_already_carries_a_host_id_keeps_it() {
const OTHER: &str = "deadbeefdeadbeefdeadbeefdeadbeef";
let event = CloudEvent {
envelope: serde_json::json!({"id": "evt_hook", "hostid": OTHER}),
agent_id: "agt_install".to_string(),
};
let stamped = stamp_extensions(
&event,
&SourceFormats::new(),
&SessionRegistry::default(),
Some(HOST),
);
assert_eq!(stamped["hostid"].as_str(), Some(OTHER));
}
#[test]
fn no_host_id_configured_fills_nothing() {
let stamped = stamp_extensions(
&config_event(),
&SourceFormats::new(),
&SessionRegistry::default(),
None,
);
assert!(stamped.get("hostid").is_none());
}
fn config_event() -> CloudEvent {
CloudEvent {
envelope: serde_json::json!({
"id": "evt_cfg",
"source": "claude-code",
"type": "ai.openlatch.config.modified",
"data": {"configkind": "settings"},
}),
agent_id: "agt_install".to_string(),
}
}
fn assurance_of(stamped: &serde_json::Value) -> Option<&str> {
stamped["data"][crate::model_relay::session::ASSURANCE_KEY].as_str()
}
#[test]
fn one_live_session_is_stamped_attested_on_a_subjectless_event() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_only");
let stamped = stamp_extensions(&config_event(), &SourceFormats::new(), &sessions, None);
assert_eq!(
stamped["subject"].as_str(),
Some("sess_only"),
"a config event must land in the session that was live when it happened"
);
assert_eq!(assurance_of(&stamped), Some("attested"));
}
#[test]
fn concurrent_sessions_are_stamped_inferred() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_old");
std::thread::sleep(std::time::Duration::from_millis(5));
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_new");
let stamped = stamp_extensions(&config_event(), &SourceFormats::new(), &sessions, None);
assert_eq!(stamped["subject"].as_str(), Some("sess_new"));
assert_eq!(assurance_of(&stamped), Some("inferred"));
}
#[test]
fn a_session_of_a_different_agent_is_never_borrowed() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "cline", "conv_cline_1");
let stamped = stamp_extensions(&config_event(), &SourceFormats::new(), &sessions, None);
assert_eq!(
stamped["subject"].as_str(),
Some("agt_install"),
"a Claude Code event falls back to the install bucket rather than \
joining a live Cline conversation"
);
assert_eq!(
assurance_of(&stamped),
Some("unknown"),
"and says plainly that no session of its own agent was claimed"
);
}
#[test]
fn the_events_own_agent_still_wins_its_session() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "cline", "conv_cline_1");
std::thread::sleep(std::time::Duration::from_millis(5));
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_cc");
std::thread::sleep(std::time::Duration::from_millis(5));
sessions.upsert("agt_install", "agt_install", "cline", "conv_cline_2");
let stamped = stamp_extensions(&config_event(), &SourceFormats::new(), &sessions, None);
assert_eq!(stamped["subject"].as_str(), Some("sess_cc"));
assert_eq!(assurance_of(&stamped), Some("attested"));
}
#[test]
fn no_live_session_buckets_the_event_under_the_install_id() {
let event = config_event();
let stamped = stamp_extensions(
&event,
&SourceFormats::new(),
&SessionRegistry::default(),
None,
);
assert_eq!(stamped["subject"].as_str(), Some("agt_install"));
assert_eq!(stamped["subject"].as_str(), Some(event.agent_id.as_str()));
assert_eq!(assurance_of(&stamped), Some("unknown"));
}
#[test]
fn a_null_data_becomes_the_object_the_assurance_lands_in() {
let event = CloudEvent {
envelope: serde_json::json!({
"id": "evt_removed",
"type": "ai.openlatch.config.removed",
"data": serde_json::Value::Null,
}),
agent_id: "agt_install".to_string(),
};
let stamped = stamp_extensions(
&event,
&SourceFormats::new(),
&SessionRegistry::default(),
None,
);
assert_eq!(assurance_of(&stamped), Some("unknown"));
}
#[test]
fn a_hook_events_subject_and_raw_payload_are_left_alone() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_other");
let data = serde_json::json!({"session_id": "sess_hook", "tool_name": "Bash"});
let event = CloudEvent {
envelope: serde_json::json!({
"id": "evt_hook",
"source": "claude-code",
"type": "ai.openlatch.hook.pre_tool_use",
"subject": "sess_hook",
"data": data.clone(),
}),
agent_id: "agt_install".to_string(),
};
let stamped = stamp_extensions(&event, &SourceFormats::new(), &sessions, None);
assert_eq!(
stamped["subject"].as_str(),
Some("sess_hook"),
"the agent's own session id must not be replaced by a correlation"
);
assert_eq!(stamped["data"], data, "the agent's raw payload is opaque");
}
#[test]
fn a_model_relay_row_keeps_the_assurance_the_cascade_recorded() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_live");
let event = CloudEvent {
envelope: serde_json::json!({
"id": "evt_usage",
"source": "claude-code",
"type": "ai.openlatch.economics.usage",
"subject": "sess_model_relay",
"data": {
crate::model_relay::session::ASSURANCE_KEY: "inferred",
},
}),
agent_id: "agt_install".to_string(),
};
let stamped = stamp_extensions(&event, &SourceFormats::new(), &sessions, None);
assert_eq!(stamped["subject"].as_str(), Some("sess_model_relay"));
assert_eq!(assurance_of(&stamped), Some("inferred"));
}
#[test]
fn re_stamping_a_spooled_envelope_is_idempotent() {
let sessions = SessionRegistry::default();
sessions.upsert("agt_install", "agt_install", "claude-code", "sess_first");
let once = stamp_extensions(&config_event(), &SourceFormats::new(), &sessions, None);
let later = SessionRegistry::default();
later.upsert("agt_install", "agt_install", "claude-code", "sess_second");
let replayed = stamp_extensions(
&CloudEvent {
envelope: once.clone(),
agent_id: "agt_install".to_string(),
},
&SourceFormats::new(),
&later,
None,
);
assert_eq!(replayed["subject"], once["subject"]);
assert_eq!(assurance_of(&replayed), Some("attested"));
}
#[tokio::test]
async fn clientversion_stamped_on_every_envelope() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 3,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..6 {
let envelope = if i % 2 == 0 {
serde_json::json!({"id": format!("evt_{i}")})
} else {
serde_json::json!({"id": format!("evt_{i}"), "clientversion": "0.0.1-stale"})
};
tx.send(CloudEvent {
envelope,
agent_id: format!("agt_{i}"),
})
.await
.unwrap();
}
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
let bodies = captured.lock().unwrap().clone();
let mut seen = 0;
for body in &bodies {
let elements: Vec<serde_json::Value> = serde_json::from_str(body).unwrap();
for element in elements {
assert_eq!(
element["clientversion"].as_str(),
Some(env!("OPENLATCH_VERSION")),
"every envelope on the wire must carry the forwarder version: {element}"
);
seen += 1;
}
}
assert_eq!(seen, 6, "every enqueued event must reach the wire");
}
#[tokio::test]
async fn ingest_carries_the_host_key_and_omits_it_when_there_is_none() {
let dir = tempfile::tempdir().unwrap();
let key = SecretString::from("test-api-key".to_string());
let batch = vec![prepare_one(serde_json::json!({"id": "evt_1"})).unwrap()];
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.match_header("x-openlatch-machine-id", HOST)
.with_status(200)
.with_body("{}")
.expect(1)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
host_key: Some(HOST.to_string()),
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect("a matched request answers 200");
mock.assert_async().await;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.match_header("x-openlatch-machine-id", mockito::Matcher::Missing)
.with_status(200)
.with_body("{}")
.expect(1)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect("a request with no machine id answers 200");
mock.assert_async().await;
}
#[tokio::test]
async fn ingest_carries_the_agent_id_on_the_batch_and_the_probe_and_omits_it_when_there_is_none(
) {
const AGENT: &str = "agt_test_448";
let key = SecretString::from("test-api-key".to_string());
let batch = vec![prepare_one(serde_json::json!({"id": "evt_1"})).unwrap()];
let dir = tempfile::tempdir().unwrap();
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.match_header("x-openlatch-agent-id", AGENT)
.with_status(200)
.with_body("{}")
.expect(1)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
agent_id: Some(AGENT.to_string()),
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect("a matched request answers 200");
mock.assert_async().await;
let dir = tempfile::tempdir().unwrap();
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.match_body("[]")
.match_header("x-openlatch-agent-id", AGENT)
.with_status(200)
.with_header(
crate::core::protocol::contracts::SELECTED_HEADER,
"decision_event=1",
)
.with_body("{}")
.expect(1)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
agent_id: Some(AGENT.to_string()),
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
renegotiate_event_writer(
&client,
&config,
&key,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect("a matched probe answers 200");
mock.assert_async().await;
let dir = tempfile::tempdir().unwrap();
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/events/ingest")
.match_header("x-openlatch-agent-id", mockito::Matcher::Missing)
.with_status(200)
.with_body("{}")
.expect(1)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect("a request with no agent id answers 200");
mock.assert_async().await;
}
#[tokio::test]
async fn error_mapping_unchanged() {
let dir = tempfile::tempdir().unwrap();
let key = SecretString::from("test-api-key".to_string());
let batch = vec![prepare_one(serde_json::json!({"id": "evt_1"})).unwrap()];
for (status, header, body, expected) in [
(401u16, None, "{}", CloudError::AuthError),
(403, None, "{}", CloudError::AuthError),
(
429,
Some(("Retry-After", "7")),
"{}",
CloudError::RateLimit {
retry_after_secs: 7,
},
),
(
402,
Some(("Retry-After", "900")),
I2_LICENSE_BODY,
CloudError::LicenseRefused {
code: "license_expired".to_string(),
licensing_url: Some(LICENSING_URL.to_string()),
retry_after: Some(Duration::from_secs(900)),
},
),
(
402,
None,
r#"{"error":{"code":"license_expired","licensing_url":"https://app.openlatch.ai/settings/licensing"}}"#,
CloudError::LicenseRefused {
code: "license_expired".to_string(),
licensing_url: Some(LICENSING_URL.to_string()),
retry_after: None,
},
),
(
402,
None,
"<html>402</html>",
CloudError::LicenseRefused {
code: "license_unknown".to_string(),
licensing_url: None,
retry_after: None,
},
),
(
402,
Some(("Retry-After", "99999")),
I2_LICENSE_BODY,
CloudError::LicenseRefused {
code: "license_expired".to_string(),
licensing_url: Some(LICENSING_URL.to_string()),
retry_after: Some(Duration::from_secs(MAX_RETRY_AFTER_SECS)),
},
),
(
503,
None,
r#"{"error":{"code":"site_license_expired"}}"#,
CloudError::LicenseRefused {
code: "site_license_expired".to_string(),
licensing_url: None,
retry_after: None,
},
),
(
503,
None,
r#"{"error":{"code":"clock_regression"}}"#,
CloudError::LicenseRefused {
code: "clock_regression".to_string(),
licensing_url: None,
retry_after: None,
},
),
(500, None, "{}", CloudError::ServerError),
(503, None, "{}", CloudError::ServerError),
(418, None, "{}", CloudError::ClientError(418)),
] {
let mut server = mockito::Server::new_async().await;
let mut mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(status as usize);
if let Some((name, value)) = header {
mock = mock.with_header(name, value);
}
let _mock = mock.with_body(body).create_async().await;
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct())
.expect("client");
let err = post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect_err("non-2xx must map to an error");
assert_eq!(
format!("{err:?}"),
format!("{expected:?}"),
"HTTP {status} must map to {expected:?}"
);
}
{
let mut server = mockito::Server::new_async().await;
let future = chrono::Utc::now() + chrono::Duration::seconds(600);
let _mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(402)
.with_header(
"Retry-After",
&future.format("%a, %d %b %Y %H:%M:%S GMT").to_string(),
)
.with_body(I2_LICENSE_BODY)
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct())
.expect("client");
let err = post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.expect_err("a 402 must map to a refusal");
let CloudError::LicenseRefused { retry_after, .. } = err else {
panic!("an http-date Retry-After must still be a refusal: {err:?}");
};
let secs = retry_after.expect("the date form must parse").as_secs();
assert!(
(590..=600).contains(&secs),
"expected ~600s from an http-date, got {secs}"
);
}
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/api/v1/events/ingest")
.with_status(429)
.with_body("{}")
.create_async()
.await;
let config = CloudConfig {
api_url: server.url(),
rate_limit_default_secs: 42,
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
let err = post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap_err();
assert!(
matches!(
err,
CloudError::RateLimit {
retry_after_secs: 42
}
),
"missing Retry-After must fall back to rate_limit_default_secs, got {err:?}"
);
let config = CloudConfig {
api_url: "http://127.0.0.1:1".to_string(),
timeout_connect_ms: 500,
timeout_total_ms: 1000,
..Default::default()
};
let client =
build_cloud_client(&config, &crate::egress::EgressConfig::direct()).expect("client");
let err = post_batch(
&client,
&config,
&key,
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap_err();
assert!(
matches!(err, CloudError::Network),
"transport failure must map to Network, got {err:?}"
);
}
#[tokio::test]
async fn event_negotiation_is_independent_and_no_ack_keeps_the_existing_writer() {
let mut server = mockito::Server::new_async().await;
let post = server
.mock("POST", "/api/v1/events/ingest")
.match_header(
crate::core::protocol::contracts::SUPPORT_HEADER,
"decision_event=1-2,policy_bundle=1-2",
)
.with_status(200)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
crate::core::protocol::contracts::record_diagnostic(
dir.path(),
crate::core::protocol::contracts::CompatibilityDiagnostic {
family: crate::core::protocol::contracts::POLICY_BUNDLE_FAMILY.to_string(),
client_range: crate::core::protocol::contracts::VersionRange::new(1, 2).unwrap(),
platform_range: Some(
crate::core::protocol::contracts::VersionRange::new(3, 4).unwrap(),
),
last_selection: None,
observed_at: chrono::Utc::now().to_rfc3339(),
detail: "bundle unavailable".to_string(),
},
)
.unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap();
let batch = vec![prepare_one(serde_json::json!({
"id":"evt-v2", "olverdict":"block", "olresult":"blocked"
}))
.unwrap()];
post_batch(
&client,
&config,
&SecretString::from("key".to_string()),
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap();
post.assert_async().await;
let state = crate::core::protocol::contracts::read_compatibility(dir.path()).unwrap();
assert!(state
.diagnostics
.contains_key(crate::core::protocol::contracts::POLICY_BUNDLE_FAMILY));
assert!(!state
.selections
.contains_key(crate::core::protocol::contracts::DECISION_EVENT_FAMILY));
}
#[tokio::test]
async fn event_ack_drives_next_writer_and_refuses_lossy_lowering() {
let mut server = mockito::Server::new_async().await;
let ack = server
.mock("POST", "/api/v1/events/ingest")
.with_status(200)
.with_header(
crate::core::protocol::contracts::SELECTED_HEADER,
"decision_event=1",
)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap();
let key = SecretString::from("key".to_string());
let v1 = vec![prepare_one(serde_json::json!({"id":"evt-v1","olverdict":"allow"})).unwrap()];
post_batch(
&client,
&config,
&key,
&v1,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap();
ack.assert_async().await;
assert_eq!(
crate::core::protocol::contracts::read_compatibility(dir.path())
.unwrap()
.selections[crate::core::protocol::contracts::DECISION_EVENT_FAMILY]
.version,
1
);
let still_v1 = server
.mock("POST", "/api/v1/events/ingest")
.match_body("[]")
.with_status(200)
.with_header(
crate::core::protocol::contracts::SELECTED_HEADER,
"decision_event=1",
)
.expect(1)
.create_async()
.await;
let v2 = vec![prepare_one(serde_json::json!({
"id":"evt-lossy", "olverdict":"ask", "olenforced":0, "olresult":"deferred"
}))
.unwrap()];
let error = post_batch(
&client,
&config,
&key,
&v2,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap_err();
assert!(matches!(error, CloudError::CompatibilityUnavailable));
still_v1.assert_async().await;
assert!(
crate::core::protocol::contracts::read_compatibility(dir.path())
.unwrap()
.diagnostics
.contains_key(crate::core::protocol::contracts::DECISION_EVENT_FAMILY)
);
}
#[tokio::test]
async fn a_v1_selection_can_renegotiate_to_v2_before_sending_v2_facts() {
let mut server = mockito::Server::new_async().await;
let probe = server
.mock("POST", "/api/v1/events/ingest")
.match_body("[]")
.with_status(200)
.with_header(
crate::core::protocol::contracts::SELECTED_HEADER,
"decision_event=2",
)
.expect(1)
.create_async()
.await;
let facts = server
.mock("POST", "/api/v1/events/ingest")
.match_body(mockito::Matcher::Regex("evt-recovered".to_string()))
.with_status(200)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
crate::core::protocol::contracts::record_selection(
dir.path(),
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
1,
)
.unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap();
let batch = vec![prepare_one(serde_json::json!({
"id":"evt-recovered", "olverdict":"block", "olenforced":1, "olresult":"blocked"
}))
.unwrap()];
post_batch(
&client,
&config,
&SecretString::from("key".to_string()),
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap();
probe.assert_async().await;
facts.assert_async().await;
let state = crate::core::protocol::contracts::read_compatibility(dir.path()).unwrap();
assert_eq!(
state.selections[crate::core::protocol::contracts::DECISION_EVENT_FAMILY].version,
2
);
assert!(!state
.diagnostics
.contains_key(crate::core::protocol::contracts::DECISION_EVENT_FAMILY));
}
#[tokio::test]
async fn a_platform_that_refuses_the_empty_probe_never_receives_v2_facts() {
let mut server = mockito::Server::new_async().await;
let probe = server
.mock("POST", "/api/v1/events/ingest")
.match_body("[]")
.with_status(422)
.expect(1)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
crate::core::protocol::contracts::record_selection(
dir.path(),
crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
1,
)
.unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap();
let batch = vec![prepare_one(serde_json::json!({
"id":"evt-retained", "olverdict":"optimize", "olresult":"rewritten"
}))
.unwrap()];
let error = post_batch(
&client,
&config,
&SecretString::from("key".to_string()),
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap_err();
assert!(matches!(error, CloudError::CompatibilityUnavailable));
probe.assert_async().await;
}
#[tokio::test]
async fn typed_event_mismatch_is_retryable_compatibility_state() {
let mut server = mockito::Server::new_async().await;
let mismatch = server
.mock("POST", "/api/v1/events/ingest")
.with_status(409)
.with_body(
serde_json::json!({
"type": crate::core::protocol::contracts::COMPATIBILITY_PROBLEM_TYPE,
"family": crate::core::protocol::contracts::DECISION_EVENT_FAMILY,
"platform_range": "3-4"
})
.to_string(),
)
.create_async()
.await;
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
..Default::default()
};
let client = build_cloud_client(&config, &crate::egress::EgressConfig::direct()).unwrap();
let batch = vec![prepare_one(serde_json::json!({"id":"evt-compat"})).unwrap()];
let error = post_batch(
&client,
&config,
&SecretString::from("key".to_string()),
&batch,
dir.path(),
&crate::egress::EgressReporter::direct(),
)
.await
.unwrap_err();
assert!(matches!(error, CloudError::CompatibilityUnavailable));
mismatch.assert_async().await;
let diagnostic = &crate::core::protocol::contracts::read_compatibility(dir.path())
.unwrap()
.diagnostics[crate::core::protocol::contracts::DECISION_EVENT_FAMILY];
assert_eq!(
diagnostic.platform_range,
crate::core::protocol::contracts::VersionRange::new(3, 4)
);
}
#[test]
fn a_route_with_recent_traffic_issues_no_probe() {
let cfg = crate::egress::EgressConfig::direct();
let state = crate::egress::EgressState::new(&cfg);
let egress = crate::egress::EgressReporter::recording(&cfg, state.clone());
assert!(
health_probe_due(&egress),
"a host that has recorded nothing yet is silent, and silence is what the probe is for"
);
state.record_ok();
assert!(
!health_probe_due(&egress),
"traffic is flowing — probing would ask a question already answered"
);
let failing = crate::egress::EgressState::new(&cfg);
let failing_egress = crate::egress::EgressReporter::recording(&cfg, failing.clone());
failing.record_failure(crate::error::ERR_PROXY_UNREACHABLE, "refused");
assert!(!health_probe_due(&failing_egress));
}
#[test]
fn a_route_that_reports_nowhere_probes_exactly_as_before() {
assert!(health_probe_due(&crate::egress::EgressReporter::direct()));
}
#[tokio::test]
async fn existing_select_branches_still_fire() {
let mut server = mockito::Server::new_async().await;
let health = server
.mock("GET", "/api/v1/health")
.with_status(200)
.with_body(r#"{"status":"ok"}"#)
.expect_at_least(1)
.create_async()
.await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(20);
let provider = TestCredentialProvider::with_key("test-api-key");
let observer = provider.clone();
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
credential_poll_interval_ms: 50,
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
provider,
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..3 {
tx.send(evt(&format!("evt_{i}"))).await.unwrap();
}
assert!(
wait_until(
|| observer.retrievals() >= 2 && health.matched(),
std::time::Duration::from_secs(5),
)
.await,
"the health and credential-poll branches must keep firing while a batch waits"
);
assert!(
captured.lock().unwrap().is_empty(),
"the buffer must still be held — otherwise this proves nothing"
);
health.assert_async().await;
assert!(
observer.retrievals() >= 2,
"the credential-poll branch must keep firing while a batch waits, got {} retrievals",
observer.retrievals()
);
assert_eq!(
state.consecutive_probe_failures(),
0,
"successful health probes must still be recorded"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), handle).await;
assert_eq!(batch_sizes(&captured), vec![3]);
}
#[tokio::test]
async fn due_timer_wins_over_saturated_channel() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(64);
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 100_000,
batch_max_wait_ms: 25,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
CloudState::new(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
let producer_tx = tx.clone();
let producer = tokio::spawn(async move {
for i in 0..2000u32 {
if producer_tx.send(evt(&format!("evt_{i:04}"))).await.is_err() {
break;
}
if i % 20 == 0 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
}
});
producer.await.unwrap();
let mid_stream = captured.lock().unwrap().len();
assert!(
mid_stream >= 2,
"the due timer must win against a continuously-ready channel — \
expected multiple mid-stream flushes, got {mid_stream}"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), handle).await;
}
#[tokio::test]
async fn forwarded_count_is_batch_length() {
let mut server = mockito::Server::new_async().await;
let _health = quiet_health(&mut server).await;
let (_ingest, captured) = capture_ingest(&mut server, 200).await;
let (tx, rx) = mpsc::channel::<CloudEvent>(100);
let state = CloudState::new();
let dir = tempfile::tempdir().unwrap();
let config = CloudConfig {
api_url: server.url(),
batch_max_events: 50,
batch_max_wait_ms: 600_000,
..Default::default()
};
let handle = tokio::spawn(run_cloud_worker(
rx,
TestCredentialProvider::with_key("test-api-key"),
config,
crate::egress::EgressReporter::direct(),
state.clone(),
dir.path().to_path_buf(),
None,
None,
SourceFormats::new(),
no_sessions(),
));
for i in 0..50 {
tx.send(evt(&format!("evt_{i:02}"))).await.unwrap();
}
assert!(
wait_until(
|| state.forwarded_count() == 50,
std::time::Duration::from_secs(5),
)
.await,
"all 50 events must be forwarded"
);
assert_eq!(
batch_sizes(&captured),
vec![50],
"one request for 50 events"
);
assert_eq!(
state.forwarded_count(),
50,
"forwarded_count must be the batch LENGTH, not 1"
);
drop(tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
fn prepared_of(size: usize, count: usize) -> Vec<PreparedEvent> {
(0..count)
.map(|i| {
prepare_one(serde_json::json!({"id": format!("evt_{i}"), "data": "x".repeat(size)}))
.expect("fixture must be sendable")
})
.collect()
}
#[test]
fn split_batches_caps_on_element_count() {
let prepared = prepared_of(8, 250);
let groups = split_batches(&prepared, 50);
assert_eq!(
groups.iter().map(|g| g.len()).collect::<Vec<_>>(),
vec![50; 5]
);
}
#[test]
fn split_batches_never_exceeds_the_platform_element_cap() {
let prepared = prepared_of(8, 250);
let groups = split_batches(&prepared, 100_000);
assert!(groups.iter().all(|g| g.len() <= MAX_BATCH_EVENTS));
assert_eq!(groups[0].len(), MAX_BATCH_EVENTS);
}
#[test]
fn split_batches_closes_on_the_byte_cap() {
let prepared = prepared_of(16_000, 40);
let groups = split_batches(&prepared, 100);
for group in &groups {
let framed: usize = group.iter().map(|e| e.json.len()).sum::<usize>()
+ 2
+ group.len().saturating_sub(1);
assert!(
framed <= MAX_BATCH_BYTES,
"group of {} serialises to {framed} bytes",
group.len()
);
}
assert_eq!(
groups.iter().map(|g| g.len()).sum::<usize>(),
40,
"the split must not lose events"
);
assert!(groups.len() > 1, "40 x 16KB cannot be one request");
}
#[test]
fn split_batches_handles_a_single_event() {
let prepared = prepared_of(8, 1);
let groups = split_batches(&prepared, 50);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].len(), 1);
}
#[test]
fn prepare_one_rejects_an_unbatchable_event() {
assert!(
prepare_one(serde_json::json!({"id": "big", "data": "x".repeat(300_000)})).is_none(),
"an event that cannot fit in any batch must be dropped, not retried forever"
);
assert!(prepare_one(serde_json::json!({"id": "small"})).is_some());
}
}