Skip to main content

klieo_core/
bus.rs

1//! Inter-agent bus traits — Pubsub, RequestReply, KvStore, JobQueue.
2//!
3//! Trait shapes mirror NATS JetStream + KV semantics so the production
4//! impl (`klieo-bus-nats`) is a thin adapter, and the in-process impl
5//! (`klieo-bus-memory`) can faithfully simulate them. See the spec for
6//! reliability invariants.
7
8use crate::error::BusError;
9use crate::ids::{DurableName, JobId, RunId};
10use async_trait::async_trait;
11use bytes::Bytes;
12use futures_core::Stream;
13use std::collections::HashMap;
14use std::pin::Pin;
15use std::time::Duration;
16
17/// Opaque headers accompanying a bus message.
18pub type Headers = HashMap<String, String>;
19
20/// Bus header carrying the publisher's run id, threaded by
21/// `AgentContext::publish` so a receiver can record which run caused a
22/// delivery (see [`crate::Episode::BusCausalLink`]).
23pub const CAUSATION_HEADER: &str = "klieo-causation-run";
24
25/// One message delivered to a subscriber.
26pub struct Msg {
27    /// Subject the message was published on.
28    pub subject: String,
29    /// Payload bytes.
30    pub payload: Bytes,
31    /// Headers.
32    pub headers: Headers,
33    /// Acknowledgement handle. The impl provides the underlying mechanism;
34    /// callers must invoke exactly one of `ack` / `nak` / `term` per
35    /// message or rely on visibility-timeout redelivery.
36    pub ack: AckHandle,
37}
38
39impl std::fmt::Debug for Msg {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("Msg")
42            .field("subject", &self.subject)
43            .field("payload_len", &self.payload.len())
44            .field("headers", &self.headers)
45            .finish()
46    }
47}
48
49/// Acknowledgement handle attached to a delivered [`Msg`].
50pub struct AckHandle(Box<dyn AckHandleImpl>);
51
52impl AckHandle {
53    /// Construct from an impl. Bus implementations call this when delivering messages.
54    pub fn new(inner: Box<dyn AckHandleImpl>) -> Self {
55        Self(inner)
56    }
57}
58
59/// Implementor side of an ack handle. Exposed so impls can supply backend
60/// behaviour without callers reaching into private types.
61#[async_trait]
62pub trait AckHandleImpl: Send + Sync {
63    /// Acknowledge successful processing.
64    async fn ack(self: Box<Self>) -> Result<(), BusError>;
65    /// Negative-ack with optional redelivery delay. Triggers redelivery
66    /// after `delay`.
67    async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
68    /// Terminate the message (do not redeliver).
69    async fn term(self: Box<Self>) -> Result<(), BusError>;
70}
71
72impl AckHandle {
73    /// Acknowledge.
74    pub async fn ack(self) -> Result<(), BusError> {
75        self.0.ack().await
76    }
77    /// Negative-ack.
78    pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
79        self.0.nak(delay).await
80    }
81    /// Terminate.
82    pub async fn term(self) -> Result<(), BusError> {
83        self.0.term().await
84    }
85}
86
87/// Stream of messages delivered to a subscriber.
88pub type MsgStream = Pin<Box<dyn Stream<Item = Result<Msg, BusError>> + Send + 'static>>;
89
90/// Pub/sub interface (subject-based, durable consumers).
91///
92/// ```
93/// # tokio_test::block_on(async {
94/// use klieo_core::test_utils::noop_bus;
95/// use klieo_core::{Headers, Pubsub};
96/// use bytes::Bytes;
97/// let (pubsub, _, _, _) = noop_bus();
98/// pubsub.publish("subject.demo", Bytes::from_static(b"hi"), Headers::new())
99///     .await.unwrap();
100/// # });
101/// ```
102#[async_trait]
103pub trait Pubsub: Send + Sync {
104    /// Publish `payload` on `subject` with `headers`.
105    async fn publish(
106        &self,
107        subject: &str,
108        payload: Bytes,
109        headers: Headers,
110    ) -> Result<(), BusError>;
111
112    /// Subscribe with a durable consumer name. Multiple calls with the
113    /// same `durable` form a competing-consumer group sharing replays.
114    async fn subscribe(&self, subject: &str, durable: DurableName) -> Result<MsgStream, BusError>;
115}
116
117/// Validate that `token` is safe to embed as a single NATS subject segment.
118///
119/// NATS reserves `.` (separator), `*` (single-token wildcard), and `>`
120/// (greedy wildcard) as subject metacharacters. Whitespace, control
121/// characters, and non-ASCII bytes are also rejected so wire-level
122/// subjects remain printable and the segment cannot collapse the
123/// caller-controlled subject namespace.
124///
125/// Use this at every site that embeds a caller-influenced identifier
126/// (progressToken, task id, stream id, …) into a publish or subscribe
127/// subject. Skipping validation is a cross-tenant data-leak vector
128/// (CWE-74 subject injection): a single-character token like `>` or
129/// `*` would subscribe to every other tenant's stream.
130///
131/// Returns `BusError::Invalid` on any rejected segment.
132pub fn validate_subject_token(token: &str) -> Result<(), BusError> {
133    if token.is_empty() {
134        return Err(BusError::Invalid("subject segment is empty".into()));
135    }
136    for byte in token.bytes() {
137        let forbidden = matches!(byte, b'.' | b'*' | b'>')
138            || byte.is_ascii_whitespace()
139            || byte.is_ascii_control()
140            || !byte.is_ascii();
141        if forbidden {
142            return Err(BusError::Invalid(format!(
143                "subject segment contains forbidden character (byte 0x{byte:02x})"
144            )));
145        }
146    }
147    Ok(())
148}
149
150#[cfg(test)]
151mod subject_token_tests {
152    use super::*;
153
154    #[test]
155    fn accepts_uuid_like_token() {
156        validate_subject_token("550e8400-e29b-41d4-a716-446655440000").unwrap();
157        validate_subject_token("task_42").unwrap();
158        validate_subject_token("abc123").unwrap();
159    }
160
161    #[test]
162    fn rejects_empty() {
163        let e = validate_subject_token("").unwrap_err();
164        assert!(matches!(e, BusError::Invalid(_)));
165    }
166
167    #[test]
168    fn rejects_greedy_wildcard() {
169        assert!(matches!(
170            validate_subject_token(">"),
171            Err(BusError::Invalid(_))
172        ));
173    }
174
175    #[test]
176    fn rejects_single_token_wildcard() {
177        assert!(matches!(
178            validate_subject_token("*"),
179            Err(BusError::Invalid(_))
180        ));
181    }
182
183    #[test]
184    fn rejects_dot_separator() {
185        assert!(matches!(
186            validate_subject_token("a.b"),
187            Err(BusError::Invalid(_))
188        ));
189    }
190
191    #[test]
192    fn rejects_whitespace_and_control() {
193        assert!(matches!(
194            validate_subject_token("a b"),
195            Err(BusError::Invalid(_))
196        ));
197        assert!(matches!(
198            validate_subject_token("a\nb"),
199            Err(BusError::Invalid(_))
200        ));
201        assert!(matches!(
202            validate_subject_token("a\tb"),
203            Err(BusError::Invalid(_))
204        ));
205    }
206
207    #[test]
208    fn rejects_non_ascii() {
209        assert!(matches!(
210            validate_subject_token("café"),
211            Err(BusError::Invalid(_))
212        ));
213    }
214}
215
216/// Synchronous request/response over the bus.
217///
218/// ```
219/// # tokio_test::block_on(async {
220/// use klieo_core::test_utils::noop_bus;
221/// use klieo_core::{BusError, RequestReply};
222/// use bytes::Bytes;
223/// use std::time::Duration;
224/// let (_, request_reply, _, _) = noop_bus();
225/// let err = request_reply
226///     .request("svc.add", Bytes::from_static(b"1"), Duration::from_secs(1))
227///     .await
228///     .unwrap_err();
229/// assert!(matches!(err, BusError::NotFound(_)));
230/// # });
231/// ```
232#[async_trait]
233pub trait RequestReply: Send + Sync {
234    /// Send a request and await one reply, bounded by `timeout`.
235    async fn request(
236        &self,
237        subject: &str,
238        payload: Bytes,
239        timeout: Duration,
240    ) -> Result<Bytes, BusError>;
241}
242
243/// CAS revision returned by KV writes.
244pub type Revision = u64;
245
246/// One KV entry.
247#[derive(Debug, Clone)]
248pub struct KvEntry {
249    /// Stored value.
250    pub value: Bytes,
251    /// Revision number after the last write.
252    pub revision: Revision,
253}
254
255/// One page of bucket keys plus an opaque cursor for the next page.
256#[derive(Debug, Clone, PartialEq, Eq)]
257#[non_exhaustive]
258pub struct KeyPage {
259    /// Keys in this page, ascending. Empty iff no key sorts at or after the
260    /// requested cursor.
261    pub keys: Vec<String>,
262    /// Cursor to pass as the next call's `cursor`; `None` once the final key
263    /// has been returned.
264    pub next: Option<String>,
265}
266
267impl KeyPage {
268    /// Build a page from its keys and next-cursor. The constructor lets
269    /// out-of-crate backends return a `KeyPage` despite `#[non_exhaustive]`.
270    pub fn new(keys: Vec<String>, next: Option<String>) -> Self {
271        Self { keys, next }
272    }
273}
274
275/// Bucket-keyed durable KV store with CAS.
276///
277/// ```
278/// # tokio_test::block_on(async {
279/// use klieo_core::test_utils::noop_bus;
280/// use klieo_core::KvStore;
281/// use bytes::Bytes;
282/// let (_, _, kv, _) = noop_bus();
283/// let rev = kv.put("bucket", "key", Bytes::from_static(b"v")).await.unwrap();
284/// assert_eq!(rev, 1);
285/// # });
286/// ```
287#[async_trait]
288pub trait KvStore: Send + Sync {
289    /// Read a value.
290    async fn get(&self, bucket: &str, key: &str) -> Result<Option<KvEntry>, BusError>;
291
292    /// Unconditional write. Returns the new revision.
293    async fn put(&self, bucket: &str, key: &str, value: Bytes) -> Result<Revision, BusError>;
294
295    /// Compare-and-set. `expected = None` requires the key to be absent;
296    /// `Some(rev)` requires the current revision to equal `rev`. Returns
297    /// the new revision on success.
298    async fn cas(
299        &self,
300        bucket: &str,
301        key: &str,
302        value: Bytes,
303        expected: Option<Revision>,
304    ) -> Result<Revision, BusError>;
305
306    /// Delete a key.
307    async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError>;
308
309    /// Acquire an exclusive lease over `key` for `ttl`. Implementations
310    /// should hold the lease as long as the returned [`Lease`] is live
311    /// and call its `heartbeat` to extend.
312    async fn lease(&self, bucket: &str, key: &str, ttl: Duration) -> Result<Lease, BusError>;
313
314    /// Enumerate keys under `bucket`. Default impl returns
315    /// `BusError::Unsupported` — backends that can scan (in-mem,
316    /// NATS JetStream KV) override this. The resume-buffer sweeper
317    /// (see `klieo-core::resume`) calls this to walk all buckets;
318    /// backends that cannot enumerate skip proactive eviction and
319    /// rely on access-time TTL checks.
320    async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
321        let _ = bucket;
322        Err(BusError::Unsupported(
323            "keys() not implemented for this KvStore".into(),
324        ))
325    }
326
327    /// Return all `(key, value)` pairs in `bucket` in a single logical
328    /// operation. The default falls back to [`Self::keys`] + per-key
329    /// [`Self::get`] (N+1); override for efficiency.
330    async fn scan_bucket(&self, bucket: &str) -> Result<Vec<(String, Bytes)>, BusError> {
331        let keys = self.keys(bucket).await?;
332        let mut out = Vec::with_capacity(keys.len());
333        for k in keys {
334            if is_causer_key(&k) {
335                continue;
336            }
337            if let Some(entry) = self.get(bucket, &k).await? {
338                out.push((k, entry.value));
339            }
340        }
341        Ok(out)
342    }
343
344    /// Enumerate `bucket` keys one ascending page at a time. Pass `cursor =
345    /// None` for the first page and the prior page's `next` thereafter; paging
346    /// ends when `next` is `None`. A `limit` of 0 is floored to 1 so paging
347    /// always advances.
348    ///
349    /// The cursor is the last key of the prior page and resumes strictly after
350    /// it, so pages stay stable across concurrent inserts and deletes (unlike
351    /// offset paging). The default fetches and sorts every key per call, so it
352    /// does NOT bound a large backend's cost — backends over big buckets must
353    /// override; the in-process store overrides for a reference impl.
354    async fn keys_paginated(
355        &self,
356        bucket: &str,
357        cursor: Option<String>,
358        limit: usize,
359    ) -> Result<KeyPage, BusError> {
360        let mut keys = self.keys(bucket).await?;
361        keys.sort();
362        Ok(page_from_sorted(keys, cursor.as_deref(), limit))
363    }
364
365    /// Record which `run` caused the value at `key` to be written, stored under
366    /// a reserved-prefix sibling key in the SAME bucket (no new bucket — NATS KV
367    /// bucket names are charset-restricted). First-writer-wins: the causer is
368    /// written create-if-absent, so a later writer never overwrites an existing
369    /// causer (a CAS conflict is the expected no-op, not an error). Best-effort
370    /// provenance, NOT atomic with the value write. The default is backed by
371    /// [`Self::cas`]; impls need not override.
372    async fn put_causer(&self, bucket: &str, key: &str, run: RunId) -> Result<(), BusError> {
373        match self
374            .cas(bucket, &causer_key(key), Bytes::from(run.to_string()), None)
375            .await
376        {
377            Ok(_) => Ok(()),
378            Err(BusError::CasConflict { .. }) => Ok(()),
379            Err(other) => Err(other),
380        }
381    }
382
383    /// Read the causer recorded by [`Self::put_causer`]; `None` if no causer was
384    /// written or the stored value is not a valid run id.
385    async fn causer_of(&self, bucket: &str, key: &str) -> Result<Option<RunId>, BusError> {
386        let Some(entry) = self.get(bucket, &causer_key(key)).await? else {
387            return Ok(None);
388        };
389        Ok(std::str::from_utf8(&entry.value)
390            .ok()
391            .and_then(|s| ulid::Ulid::from_string(s).ok())
392            .map(RunId))
393    }
394}
395
396/// Reserved key-prefix under which [`KvStore::put_causer`] stores a value's
397/// causer run id, as a sibling key in the same bucket as the value. Uses only
398/// NATS-KV-safe characters (`[-/_=.a-zA-Z0-9]`) so the default impl works on
399/// every backend; the unusual prefix makes collision with a real key unlikely.
400/// `keys()` enumeration still surfaces these sibling keys — callers walking a
401/// causation bucket's raw keys should skip them via [`is_causer_key`]
402/// ([`KvStore::scan_bucket`] already filters them out).
403const KV_CAUSER_PREFIX: &str = "__klieo_causer__.";
404
405fn causer_key(key: &str) -> String {
406    format!("{KV_CAUSER_PREFIX}{key}")
407}
408
409/// Whether `key` is a reserved causer sidecar key written by
410/// [`KvStore::put_causer`] rather than an application value key. Callers that
411/// enumerate a causation bucket's raw keys (e.g. a GC sweep over [`KvStore::keys`])
412/// should skip keys for which this returns `true`.
413pub fn is_causer_key(key: &str) -> bool {
414    key.starts_with(KV_CAUSER_PREFIX)
415}
416
417/// Slice an ascending, de-duplicated key list into the page that follows
418/// `cursor` (exclusive), returning at most `limit` keys plus the next cursor.
419/// `limit` is floored to 1 so a zero never stalls a page-walk. Shared by the
420/// `keys_paginated` default and by backend overrides that have already gathered
421/// a sorted key list.
422pub(crate) fn page_from_sorted(keys: Vec<String>, cursor: Option<&str>, limit: usize) -> KeyPage {
423    let limit = limit.max(1);
424    let start = match cursor {
425        Some(c) => keys.partition_point(|k| k.as_str() <= c),
426        None => 0,
427    };
428    let end = start.saturating_add(limit).min(keys.len());
429    let page = keys.get(start..end).unwrap_or(&[]).to_vec();
430    let next = if end < keys.len() {
431        page.last().cloned()
432    } else {
433        None
434    };
435    KeyPage::new(page, next)
436}
437
438/// Lease handle. Heartbeat to extend; drop to release.
439pub struct Lease(Box<dyn LeaseImpl>);
440
441impl Lease {
442    /// Construct from an impl. KvStore implementations call this when granting a lease.
443    pub fn new(inner: Box<dyn LeaseImpl>) -> Self {
444        Self(inner)
445    }
446}
447
448/// Implementor side of a lease.
449#[async_trait]
450pub trait LeaseImpl: Send + Sync {
451    /// Extend the TTL.
452    async fn heartbeat(&self) -> Result<(), BusError>;
453}
454
455impl Lease {
456    /// Extend the TTL.
457    pub async fn heartbeat(&self) -> Result<(), BusError> {
458        self.0.heartbeat().await
459    }
460}
461
462/// Job enqueued for durable processing.
463#[derive(Debug, Clone)]
464#[non_exhaustive]
465pub struct Job {
466    /// Job payload.
467    pub payload: Bytes,
468    /// Optional dedup key — if set, the impl writes a `dedup.<queue>`
469    /// idempotency record before invoking the handler.
470    pub dedup_key: Option<String>,
471    /// Maximum redelivery attempts before routing to the DLQ subject.
472    /// `None` = use queue default (5).
473    pub max_attempts: Option<u32>,
474    /// Run id of the agent that caused this job to be enqueued — threaded by
475    /// `AgentContext::enqueue`; `None` for un-attributed enqueues. Surfaced on
476    /// `ClaimedJob` so a worker can record a causation link.
477    pub causation_run_id: Option<RunId>,
478}
479
480impl Job {
481    /// Build a job with default settings from raw bytes.
482    pub fn new(payload: impl Into<Bytes>) -> Self {
483        Self {
484            payload: payload.into(),
485            dedup_key: None,
486            max_attempts: None,
487            causation_run_id: None,
488        }
489    }
490
491    /// Start a fluent builder. Chain `.dedup(k)`, `.max_attempts(n)`, and
492    /// `.caused_by(run)` then call `.build()`.
493    pub fn builder(payload: impl Into<Bytes>) -> JobBuilder {
494        JobBuilder {
495            payload: payload.into(),
496            dedup_key: None,
497            max_attempts: None,
498            causation_run_id: None,
499        }
500    }
501
502    /// Stamp the causing run; overwrites any value already set. Typically
503    /// called for you by [`crate::agent::AgentContext::enqueue`].
504    pub fn caused_by(mut self, run: RunId) -> Self {
505        self.causation_run_id = Some(run);
506        self
507    }
508}
509
510/// Fluent builder for [`Job`].
511pub struct JobBuilder {
512    payload: Bytes,
513    dedup_key: Option<String>,
514    max_attempts: Option<u32>,
515    causation_run_id: Option<RunId>,
516}
517
518impl JobBuilder {
519    /// Set the dedup key. The queue impl writes a `dedup.<queue>`
520    /// idempotency record before invoking the handler.
521    pub fn dedup(mut self, key: impl Into<String>) -> Self {
522        self.dedup_key = Some(key.into());
523        self
524    }
525
526    /// Set the maximum redelivery attempts before routing to the DLQ.
527    pub fn max_attempts(mut self, n: u32) -> Self {
528        self.max_attempts = Some(n);
529        self
530    }
531
532    /// Set the run id that caused this job to be enqueued.
533    pub fn caused_by(mut self, run: RunId) -> Self {
534        self.causation_run_id = Some(run);
535        self
536    }
537
538    /// Finalise the builder into a [`Job`].
539    pub fn build(self) -> Job {
540        Job {
541            payload: self.payload,
542            dedup_key: self.dedup_key,
543            max_attempts: self.max_attempts,
544            causation_run_id: self.causation_run_id,
545        }
546    }
547}
548
549/// Job claimed by a worker.
550#[non_exhaustive]
551pub struct ClaimedJob {
552    /// Job id.
553    pub id: JobId,
554    /// Job payload.
555    pub payload: Bytes,
556    /// Lease handle. Caller heartbeats; on drop without ack/nak/dlq the
557    /// lease expires and the impl redelivers.
558    pub lease: Lease,
559    /// Internal handle the impl uses to mark the claim resolved.
560    pub claim: ClaimHandle,
561    /// Run id of the agent that caused the originating job to be enqueued,
562    /// carried through from [`Job::causation_run_id`]; `None` for
563    /// un-attributed enqueues. A worker uses it to record a causation link.
564    pub causation_run_id: Option<RunId>,
565}
566
567impl std::fmt::Debug for ClaimedJob {
568    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569        f.debug_struct("ClaimedJob")
570            .field("id", &self.id)
571            .field("payload_len", &self.payload.len())
572            .finish()
573    }
574}
575
576/// Handle the impl uses to resolve a claim.
577pub struct ClaimHandle(Box<dyn ClaimHandleImpl>);
578
579impl ClaimHandle {
580    /// Construct from an impl. JobQueue implementations call this when claiming a job.
581    pub fn new(inner: Box<dyn ClaimHandleImpl>) -> Self {
582        Self(inner)
583    }
584}
585
586/// Implementor side of a claim handle.
587#[async_trait]
588pub trait ClaimHandleImpl: Send + Sync {
589    /// Successful completion. Releases the lease, marks the job done.
590    async fn ack(self: Box<Self>) -> Result<(), BusError>;
591    /// Retry with backoff.
592    async fn nak(self: Box<Self>, delay: Duration) -> Result<(), BusError>;
593    /// Send to DLQ.
594    async fn dead_letter(self: Box<Self>, reason: &str) -> Result<(), BusError>;
595}
596
597impl ClaimedJob {
598    /// Construct a claimed job from its parts. JobQueue implementations call
599    /// this when claiming a job; the constructor lets out-of-crate backends
600    /// build a `ClaimedJob` despite `#[non_exhaustive]`. `causation_run_id`
601    /// defaults to `None`; set it with [`ClaimedJob::with_causation`].
602    pub fn new(id: JobId, payload: Bytes, lease: Lease, claim: ClaimHandle) -> Self {
603        Self {
604            id,
605            payload,
606            lease,
607            claim,
608            causation_run_id: None,
609        }
610    }
611
612    /// Set the causing run carried back to the claimer; `None` clears it.
613    pub fn with_causation(mut self, run: Option<RunId>) -> Self {
614        self.causation_run_id = run;
615        self
616    }
617
618    /// Heartbeat the lease.
619    pub async fn heartbeat(&self) -> Result<(), BusError> {
620        self.lease.heartbeat().await
621    }
622    /// Acknowledge successful completion.
623    pub async fn ack(self) -> Result<(), BusError> {
624        self.claim.0.ack().await
625    }
626    /// Negative-ack; retry after `delay`.
627    pub async fn nak(self, delay: Duration) -> Result<(), BusError> {
628        self.claim.0.nak(delay).await
629    }
630    /// Route to DLQ.
631    pub async fn dead_letter(self, reason: &str) -> Result<(), BusError> {
632        self.claim.0.dead_letter(reason).await
633    }
634}
635
636/// Durable competing-consumer job queue. **No ordering guarantee** —
637/// workloads requiring order must use [`Pubsub`] on a partitioned subject
638/// with one consumer per partition instead.
639///
640/// ```
641/// # tokio_test::block_on(async {
642/// use klieo_core::test_utils::noop_bus;
643/// use klieo_core::{Job, JobQueue};
644/// use bytes::Bytes;
645/// let (_, _, _, jobs) = noop_bus();
646/// let id = jobs.enqueue("queue.work", Job::new(Bytes::from_static(b"payload"))).await.unwrap();
647/// assert_eq!(id.0, "noop-0");
648/// # });
649/// ```
650#[async_trait]
651pub trait JobQueue: Send + Sync {
652    /// Enqueue a job. Returns a stable id.
653    async fn enqueue(&self, queue: &str, job: Job) -> Result<JobId, BusError>;
654
655    /// Claim the next available job. Returns `None` when the queue is
656    /// empty. Implementations may long-poll up to a small bounded
657    /// duration before returning `None`.
658    async fn claim(
659        &self,
660        queue: &str,
661        worker_id: &str,
662        lease_ttl: Duration,
663    ) -> Result<Option<ClaimedJob>, BusError>;
664}
665
666/// Resolved bundle of bus handles ready to drop into an
667/// [`crate::agent::AgentContext`] or an `App`.
668///
669/// Impl crates (`klieo-bus-memory`, `klieo-bus-nats`) provide `From`
670/// conversions; downstream code typically writes
671/// `BusHandles::from(MemoryBus::new())` and never destructures the
672/// four sub-handles by name.
673#[derive(Clone)]
674pub struct BusHandles {
675    /// Pub/sub.
676    pub pubsub: std::sync::Arc<dyn Pubsub>,
677    /// KV store.
678    pub kv: std::sync::Arc<dyn KvStore>,
679    /// Synchronous request/reply.
680    pub request_reply: std::sync::Arc<dyn RequestReply>,
681    /// Durable job queue.
682    pub jobs: std::sync::Arc<dyn JobQueue>,
683}
684
685impl BusHandles {
686    /// Build directly from four already-`Arc`-wrapped handles.
687    /// Most callers go through an impl crate's `From` instead.
688    pub fn new(
689        pubsub: std::sync::Arc<dyn Pubsub>,
690        kv: std::sync::Arc<dyn KvStore>,
691        request_reply: std::sync::Arc<dyn RequestReply>,
692        jobs: std::sync::Arc<dyn JobQueue>,
693    ) -> Self {
694        Self {
695            pubsub,
696            kv,
697            request_reply,
698            jobs,
699        }
700    }
701}
702
703// ─── W3C tracecontext propagation helpers — cluster 0.23 ──────────────
704//
705// Bus messages crossing replica boundaries carry W3C tracecontext
706// (W3C TR/trace-context) in the standard `traceparent` + `tracestate`
707// `Headers` keys so OTEL consumers on the receiving side can stitch
708// their spans as children of the publisher's span. No API break —
709// `Headers` is already `HashMap<String, String>` on `Pubsub::publish`.
710//
711// No-op when no global OTEL trace provider is installed: the
712// TraceContextPropagator silently emits no headers when the current
713// context carries no span. Pre-0.23 deployments that ignore the
714// headers continue to work unchanged.
715//
716// These helpers require the `otel` feature flag. Crates that need
717// tracecontext propagation must declare:
718//   klieo-core = { ..., features = ["otel"] }
719
720#[cfg(feature = "otel")]
721use opentelemetry::propagation::{Extractor, Injector, TextMapPropagator};
722#[cfg(feature = "otel")]
723use opentelemetry_sdk::propagation::TraceContextPropagator;
724
725/// Inject the current OpenTelemetry context's tracecontext into
726/// `headers` under the standard W3C keys (`traceparent`, optionally
727/// `tracestate`).
728///
729/// Call BEFORE `Pubsub::publish` from inside an active OTEL span
730/// (typically reached via `tracing-opentelemetry`'s
731/// `OpenTelemetrySpanExt::context` on `Span::current()`). Cluster
732/// 0.23's impl crates do the bridging — T1 only supplies the
733/// lower-level header injection given an explicit Context.
734///
735/// Requires feature `otel`.
736#[cfg(feature = "otel")]
737pub fn inject_traceparent(headers: &mut Headers, context: &opentelemetry::Context) {
738    let propagator = TraceContextPropagator::new();
739    let mut injector = HeaderMapInjector(headers);
740    propagator.inject_context(context, &mut injector);
741}
742
743/// Extract a W3C tracecontext from `headers`. Returns the default
744/// empty Context when no headers are set; callers should set the
745/// result as the parent of subsequent spans only if they care about
746/// cross-replica stitching.
747///
748/// Call AFTER `MsgStream::next` resolves.
749///
750/// Requires feature `otel`.
751#[cfg(feature = "otel")]
752pub fn extract_traceparent(headers: &Headers) -> opentelemetry::Context {
753    let propagator = TraceContextPropagator::new();
754    let extractor = HeaderMapExtractor(headers);
755    propagator.extract(&extractor)
756}
757
758#[cfg(feature = "otel")]
759struct HeaderMapInjector<'a>(&'a mut Headers);
760
761#[cfg(feature = "otel")]
762impl<'a> Injector for HeaderMapInjector<'a> {
763    fn set(&mut self, key: &str, value: String) {
764        self.0.insert(key.to_string(), value);
765    }
766}
767
768#[cfg(feature = "otel")]
769struct HeaderMapExtractor<'a>(&'a Headers);
770
771#[cfg(feature = "otel")]
772impl<'a> Extractor for HeaderMapExtractor<'a> {
773    fn get(&self, key: &str) -> Option<&str> {
774        self.0.get(key).map(|s| s.as_str())
775    }
776
777    fn keys(&self) -> Vec<&str> {
778        self.0.keys().map(|s| s.as_str()).collect()
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[allow(dead_code)]
787    fn _assert_dyn_pubsub(_: &dyn Pubsub) {}
788    #[allow(dead_code)]
789    fn _assert_dyn_request(_: &dyn RequestReply) {}
790    #[allow(dead_code)]
791    fn _assert_dyn_kv(_: &dyn KvStore) {}
792    #[allow(dead_code)]
793    fn _assert_dyn_jobs(_: &dyn JobQueue) {}
794
795    #[test]
796    fn job_builder_defaults_match_job_new() {
797        let by_builder = Job::builder(Bytes::from_static(b"x")).build();
798        let by_new = Job::new(Bytes::from_static(b"x"));
799        assert_eq!(by_builder.payload, by_new.payload);
800        assert_eq!(by_builder.dedup_key, by_new.dedup_key);
801        assert_eq!(by_builder.max_attempts, by_new.max_attempts);
802        assert_eq!(by_builder.causation_run_id, by_new.causation_run_id);
803    }
804
805    #[test]
806    fn job_carries_causation_run_id() {
807        let run = crate::ids::RunId::new();
808        let job = Job::new(Bytes::from_static(b"x")).caused_by(run);
809        assert_eq!(job.causation_run_id, Some(run));
810        let plain = Job::new(Bytes::from_static(b"y"));
811        assert_eq!(plain.causation_run_id, None);
812        let via_builder = Job::builder(Bytes::from_static(b"z"))
813            .caused_by(run)
814            .build();
815        assert_eq!(via_builder.causation_run_id, Some(run));
816    }
817
818    #[test]
819    fn job_builder_sets_dedup_and_max_attempts() {
820        let job = Job::builder(Bytes::from_static(b"payload"))
821            .dedup("idempotency-key-42")
822            .max_attempts(7)
823            .build();
824        assert_eq!(job.payload, Bytes::from_static(b"payload"));
825        assert_eq!(job.dedup_key.as_deref(), Some("idempotency-key-42"));
826        assert_eq!(job.max_attempts, Some(7));
827    }
828
829    #[test]
830    fn job_builder_accepts_string_dedup_via_into() {
831        let owned = String::from("k");
832        let job = Job::builder(Bytes::from_static(b"x")).dedup(owned).build();
833        assert_eq!(job.dedup_key.as_deref(), Some("k"));
834    }
835
836    #[test]
837    fn job_field_assignment_still_compiles() {
838        let mut job = Job::new(Bytes::from_static(b"x"));
839        job.dedup_key = Some("k".into());
840        job.max_attempts = Some(3);
841        assert_eq!(job.dedup_key.as_deref(), Some("k"));
842        assert_eq!(job.max_attempts, Some(3));
843    }
844
845    #[test]
846    fn bus_error_unsupported_renders_message() {
847        let e = BusError::Unsupported("keys() not implemented".into());
848        assert_eq!(
849            e.to_string(),
850            "unsupported operation: keys() not implemented"
851        );
852    }
853
854    #[cfg(feature = "otel")]
855    #[test]
856    fn tracecontext_inject_then_extract_roundtrip() {
857        use opentelemetry::trace::{
858            SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
859        };
860
861        let trace_id = TraceId::from_hex("0123456789abcdef0123456789abcdef").unwrap();
862        let span_id = SpanId::from_hex("0123456789abcdef").unwrap();
863        let span_ctx = SpanContext::new(
864            trace_id,
865            span_id,
866            TraceFlags::SAMPLED,
867            true,
868            TraceState::default(),
869        );
870        let cx = opentelemetry::Context::new().with_remote_span_context(span_ctx);
871
872        let mut headers: Headers = HashMap::new();
873        inject_traceparent(&mut headers, &cx);
874        assert!(
875            headers.contains_key("traceparent"),
876            "traceparent header must be injected"
877        );
878
879        let extracted = extract_traceparent(&headers);
880        let extracted_span_ctx = extracted.span().span_context().clone();
881        assert_eq!(extracted_span_ctx.trace_id(), trace_id);
882        assert_eq!(extracted_span_ctx.span_id(), span_id);
883    }
884
885    fn owned(keys: &[&str]) -> Vec<String> {
886        keys.iter().map(|k| k.to_string()).collect()
887    }
888
889    #[test]
890    fn page_from_sorted_walks_every_key_once_in_order() {
891        let keys = owned(&["a", "b", "c", "d", "e"]);
892        let mut seen = Vec::new();
893        let mut cursor: Option<String> = None;
894        loop {
895            let page = page_from_sorted(keys.clone(), cursor.as_deref(), 2);
896            assert!(page.keys.len() <= 2, "page never exceeds the limit");
897            seen.extend(page.keys);
898            match page.next {
899                Some(c) => cursor = Some(c),
900                None => break,
901            }
902        }
903        assert_eq!(seen, owned(&["a", "b", "c", "d", "e"]));
904    }
905
906    #[test]
907    fn page_from_sorted_cursor_resumes_strictly_after() {
908        // Cursor "b" must skip a and b, even if b was since deleted.
909        let page = page_from_sorted(owned(&["a", "b", "c", "d"]), Some("b"), 10);
910        assert_eq!(page.keys, owned(&["c", "d"]));
911        assert!(page.next.is_none());
912    }
913
914    #[test]
915    fn page_from_sorted_limit_at_or_past_count_is_terminal() {
916        let page = page_from_sorted(owned(&["a", "b"]), None, 2);
917        assert_eq!(page.keys, owned(&["a", "b"]));
918        assert!(page.next.is_none(), "exactly-fits page has no next cursor");
919    }
920
921    #[test]
922    fn page_from_sorted_floors_zero_limit_to_one() {
923        // limit 0 must not stall the walk: it advances by one key, not zero.
924        let page = page_from_sorted(owned(&["a", "b", "c"]), None, 0);
925        assert_eq!(page.keys, owned(&["a"]));
926        assert_eq!(page.next.as_deref(), Some("a"));
927    }
928
929    #[test]
930    fn page_from_sorted_empty_and_exhausted_cursor_yield_terminal_pages() {
931        let empty = page_from_sorted(Vec::new(), None, 4);
932        assert!(empty.keys.is_empty() && empty.next.is_none());
933
934        let past_end = page_from_sorted(owned(&["a", "b"]), Some("z"), 4);
935        assert!(past_end.keys.is_empty() && past_end.next.is_none());
936    }
937}