Skip to main content

aion_server/
activity_publisher.rs

1//! NOI-5 transcript sequencer + fan-out — the durability-critical server bridge.
2//!
3//! [`ActivityEventPublisher`] is the aion-server's SEQUENCER for the agent
4//! observability transcript. It is the counterpart of [`crate::cluster_publisher`]'s
5//! `ClusterEventPublisher` for the workflow-agnostic cluster channel, but with one
6//! decisive difference the design calls out explicitly (§5.3): the transcript's
7//! `store_seq` is **NOT a process-local `AtomicU64`**. A per-process counter resets
8//! on restart/failover, so two survivors would mint colliding, non-monotonic
9//! sequences. Instead `store_seq` is **commit-allocated**: the server reads the
10//! durable `O`-keyspace head, appends at that `expected_seq`, and on a
11//! [`StoreError::SequenceConflict`] **re-reads the advanced head and retries**.
12//!
13//! This read-head -> append(expected_seq) -> on-conflict-retry loop
14//! ([`Self::publish`]) is the ONLY thing that keeps `store_seq` monotonic when two
15//! writers race for one `(workflow, run, activity, attempt)` stream (a dying worker +
16//! an adopting worker after failover, or two concurrent publish calls). It is
17//! correctness-critical code, not an implementation detail, and is covered by the
18//! two mandatory NOI-5 negative controls: concurrent-writer monotonicity and
19//! failover dedup.
20//!
21//! # What this does and does not persist
22//!
23//! - **Non-ephemeral events** are durably appended to the `O` keyspace and then
24//!   fanned out to the live transcript broadcast (with the assigned `store_seq`).
25//! - **Ephemeral events** (token deltas) are **WS-forward-only**: fanned out live,
26//!   **never** persisted. They carry `store_seq: None` on the wire, forever.
27//!
28//! # Live fan-out + resume
29//!
30//! Persisted events are also broadcast on a bounded `broadcast::Sender<ActivityEvent>`
31//! so a connected transcript socket tails them live. A reconnecting client resumes
32//! by `store_seq`: [`Self::replay_from`] reads the durable `O` tail from the store,
33//! and [`Self::subscribe`] attaches the live broadcast suppressing any event at or
34//! below the resume cursor (the gap-free splice contract the cluster channel uses).
35//!
36//! # Batched commits (write amplification)
37//!
38//! Events do not reach this sequencer one call at a time from the outside world:
39//! they arrive on an in-process queue and a DRAIN task feeds them in
40//! (`ActivityEventPublisher::drain`, used by the liminal observability tap and
41//! by the declared-command executor). Every durable append is one storage-tree
42//! commit, and one commit re-persists the whole containing leaf as a new
43//! permanent blob — so a drain that appended one event per commit made the store
44//! cost of a run linear in EVENT COUNT rather than in event bytes (forensics
45//! 2026-08-17: 2,359 honest bytes of transcript bought a 2,303,416-byte
46//! permanent blob, ~977x; a 136 KB status-sweep run cost 73.7 MB of permanent
47//! store, 540x).
48//!
49//! The drain therefore COALESCES: it takes up to
50//! `TranscriptBatchPolicy::max_batch_events` events off the queue, waiting at
51//! most `TranscriptBatchPolicy::max_hold` for the batch to fill, and appends
52//! each stream's share as ONE commit (`Self::publish_all`). Both values are
53//! operator config with **no default** — see the durability contract below.
54
55use std::sync::Arc;
56use std::time::Duration;
57
58use aion_core::{ActivityEvent, ActivityEventKind, ProgressDetail};
59use aion_store::{ActivityRecord, ActivityStreamKey, ObservabilityStore, StoreError};
60use futures::stream::{self, BoxStream};
61use tokio::sync::{broadcast, mpsc};
62
63use crate::activity_bounds::{TranscriptBounds, bound_event};
64
65/// A lag item on the transcript broadcast: `skipped` events were dropped because
66/// the subscriber fell behind the bounded buffer. Surfaced typed to the client
67/// (which then re-resumes from the durable `O` tail), never a silent skip.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub struct TranscriptStreamLagged {
70    /// Number of transcript events dropped.
71    pub skipped: u64,
72}
73
74impl std::fmt::Display for TranscriptStreamLagged {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(
77            formatter,
78            "transcript stream lagged: {} events dropped",
79            self.skipped
80        )
81    }
82}
83
84impl std::error::Error for TranscriptStreamLagged {}
85
86/// The durable transcript sequencer + live fan-out for one deployment.
87///
88/// Cloneable: the broadcast sender and the store handle are shared, so every
89/// clone sequences into the same `O` keyspace and fans out to the same live
90/// subscribers. The store is the single source of `store_seq` monotonicity; the
91/// broadcast is best-effort live tail only.
92#[derive(Clone)]
93pub struct ActivityEventPublisher {
94    store: Arc<dyn ObservabilityStore>,
95    live: broadcast::Sender<ActivityEvent>,
96    bounds: TranscriptBounds,
97    batch: TranscriptBatchPolicy,
98}
99
100/// The transcript drain's flush policy: how many queued events one durable
101/// commit may carry, and how long the drain may wait to fill that batch.
102///
103/// # There is no default, deliberately
104///
105/// Both values are a deployment trade between store cost and transcript
106/// latency, so neither is invented here: [`crate::config::ObservabilityConfig`]
107/// carries them as `Option`s with no default and the boot path refuses to build
108/// a publisher without them (the `websocket.cluster_broadcast_capacity`
109/// pattern). A server whose operator has not ruled on them does not start, and
110/// says which keys are missing.
111///
112/// # Durability contract
113///
114/// **Unchanged for the caller of [`ActivityEventPublisher::publish`] /
115/// [`ActivityEventPublisher::publish_all`]:** those return only after the
116/// events are durably committed, and an event that has been acked as persisted
117/// (a returned `store_seq`) is exactly as durable as before.
118///
119/// **What `max_hold` extends** is the window in which an event sits in the
120/// server's IN-MEMORY transcript queue before the drain commits it. That window
121/// already existed and is already lossy: the observability tap hands events to a
122/// bounded in-process channel, and everything queued there is lost if the server
123/// process dies. `max_hold` lengthens that pre-existing window by at most its own
124/// value, and `max_batch_events` bounds how many events can be waiting in it
125/// beyond what the channel already held. Nothing that was durable becomes
126/// non-durable; a strictly bounded amount of not-yet-durable transcript stays
127/// not-yet-durable for strictly bounded longer.
128///
129/// That trade is acceptable HERE and would not be elsewhere, because the `O`
130/// keyspace is observability, never replay authority: workflow correctness lives
131/// on the `E`-stream, whose append path is untouched by this policy. Losing the
132/// last few hundred milliseconds of an agent's transcript to a kill-9 costs
133/// transcript, never a workflow decision — which is the same reason the drain
134/// already logs and drops an event the store refuses instead of failing the
135/// activity. An operator who wants the old timing sets `max_hold_ms = 0`: the
136/// drain then commits whatever is already queued the moment it can, coalescing
137/// only what genuinely arrived together, with no added window at all.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct TranscriptBatchPolicy {
140    /// Maximum events in one durable commit (`observability.max_batch_events`).
141    pub max_batch_events: std::num::NonZeroUsize,
142    /// Maximum time the drain waits for a partial batch to fill
143    /// (`observability.max_batch_hold_ms`). Zero means "never wait".
144    pub max_hold: Duration,
145}
146
147impl TranscriptBatchPolicy {
148    /// The IDENTITY policy: one event per durable commit, never held.
149    ///
150    /// This is the pre-batching behaviour, and the only policy that assumes
151    /// nothing about a deployment — which is exactly why it is the fallback for
152    /// the embedder/test state constructors that bypass config validation and
153    /// cannot refuse to build, and the policy under which the single-event
154    /// sequencing tests run. A config-driven server never reaches it: it states
155    /// its own policy or it does not start.
156    pub const UNBATCHED: Self = Self {
157        max_batch_events: std::num::NonZeroUsize::MIN,
158        max_hold: Duration::ZERO,
159    };
160}
161
162impl std::fmt::Debug for ActivityEventPublisher {
163    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        formatter
165            .debug_struct("ActivityEventPublisher")
166            .field("live_receivers", &self.live.receiver_count())
167            .finish_non_exhaustive()
168    }
169}
170
171/// The maximum number of `SequenceConflict` retries before a single `publish`
172/// gives up. In-process publishers serialize per tap (the liminal drain
173/// queue), so conflicts only come from genuine cross-process races — failover
174/// adoption, a dying worker racing its adopter — where a handful of writers
175/// contend. Exceeding this signals a pathological hot loop and must FAIL
176/// CHEAP: the 2026-07-23 flood burned a core spinning 1024-retry loops (each
177/// failed backend append also leaving orphaned store nodes behind), so the
178/// budget is sized to real contention, not to hope.
179const MAX_SEQUENCE_CONFLICT_RETRIES: usize = 16;
180
181impl ActivityEventPublisher {
182    /// Build a publisher over `store` with a live broadcast of `capacity` and
183    /// the drain's `batch` flush policy.
184    ///
185    /// `capacity` is the bounded live-tail buffer; a subscriber that lags beyond
186    /// it receives one typed [`TranscriptStreamLagged`] then re-resumes from the
187    /// durable tail. It must be non-zero (validated by the caller's config).
188    ///
189    /// `batch` is a REQUIRED argument, not an option with a fallback: the flush
190    /// policy is the operator's ruling on store cost against transcript latency
191    /// and this type refuses to invent one (see [`TranscriptBatchPolicy`]).
192    #[must_use]
193    pub fn new(
194        store: Arc<dyn ObservabilityStore>,
195        capacity: std::num::NonZeroUsize,
196        batch: TranscriptBatchPolicy,
197    ) -> Self {
198        let (live, _receiver) = broadcast::channel(capacity.get());
199        Self {
200            store,
201            live,
202            bounds: TranscriptBounds::default(),
203            batch,
204        }
205    }
206
207    /// Replace the default retention bounds with operator-configured ones
208    /// (`[observability]` config). Bounds apply to the durable append path
209    /// only; ephemeral fan-out is untouched.
210    #[must_use]
211    pub(crate) fn with_bounds(mut self, bounds: TranscriptBounds) -> Self {
212        self.bounds = bounds;
213        self
214    }
215
216    /// Sequence + persist + fan out one event.
217    ///
218    /// Ephemeral events are fanned out live with `store_seq: None` and are NEVER
219    /// persisted. Non-ephemeral events are appended to the `O` keyspace under the
220    /// commit-allocated `store_seq` (via the read-head -> `append(expected_seq)` ->
221    /// on-conflict-re-read-head-and-retry loop), then fanned out carrying that
222    /// `store_seq`. Returns the assigned `store_seq` for a persisted event, or
223    /// `None` for an ephemeral one.
224    ///
225    /// A send with no live subscribers is not an error (the calm no-dashboard
226    /// case); the durable append is the primary artifact.
227    ///
228    /// # Errors
229    /// A [`StoreError`] from the durable append (after exhausting the retry
230    /// budget on pathological contention, or any non-conflict backend error).
231    pub async fn publish(&self, event: &ActivityEvent) -> Result<Option<u64>, StoreError> {
232        let assigned = self.publish_all(std::slice::from_ref(event)).await?;
233        // `publish_all` returns one entry per input event, in input order.
234        Ok(assigned.into_iter().next().flatten())
235    }
236
237    /// Sequence + persist + fan out a batch of events as FEW durable commits as
238    /// the batch allows: one commit per distinct
239    /// `(workflow, run, activity, attempt)` stream present in `events`.
240    ///
241    /// Returns one entry per input event, in input order: the assigned
242    /// `store_seq` for a persisted event, or `None` for an ephemeral one and for
243    /// one past the stream's retention cap — the same per-event answer
244    /// [`Self::publish`] gives, because `publish` IS this method over a slice of
245    /// one.
246    ///
247    /// Events keep their arrival order within a stream, and a stream's whole
248    /// share of the batch is committed at one `expected_seq` under the same
249    /// read-head -> append -> on-conflict-re-read-head-and-retry loop the
250    /// single-event path used, so `store_seq` monotonicity and the single-writer
251    /// law are untouched: a `SequenceConflict` leaves the WHOLE batch unwritten
252    /// (the store contract is all-or-nothing) and the batch is retried at the
253    /// observed head.
254    ///
255    /// # Errors
256    /// A [`StoreError`] from a durable append. Streams are independent, so a
257    /// failure on one stream does not abandon the others: every stream in the
258    /// batch is attempted and the FIRST error is returned once they have all
259    /// been tried. (A caller with a single-stream batch — every drain call in
260    /// this server — sees exactly the single-event behaviour.)
261    pub async fn publish_all(
262        &self,
263        events: &[ActivityEvent],
264    ) -> Result<Vec<Option<u64>>, StoreError> {
265        let (outcomes, first_error) = self.publish_all_outcomes(events).await;
266        match first_error {
267            Some(error) => Err(error),
268            None => Ok(outcomes
269                .into_iter()
270                .map(|outcome| match outcome {
271                    EventOutcome::Persisted(store_seq) => Some(store_seq),
272                    EventOutcome::NotPersisted | EventOutcome::Refused => None,
273                })
274                .collect()),
275        }
276    }
277
278    /// [`Self::publish_all`] with the per-event outcome kept even when a stream
279    /// failed, so the drain can count EXACTLY how many events the store refused
280    /// rather than reporting an upper bound.
281    async fn publish_all_outcomes(
282        &self,
283        events: &[ActivityEvent],
284    ) -> (Vec<EventOutcome>, Option<StoreError>) {
285        let mut outcomes: Vec<EventOutcome> = vec![EventOutcome::NotPersisted; events.len()];
286        let mut first_error: Option<StoreError> = None;
287        // Group the durable events by stream key, preserving arrival order both
288        // between groups (first-seen key first) and inside each one.
289        let mut groups: Vec<(ActivityStreamKey, Vec<(usize, ActivityEvent)>)> = Vec::new();
290        for (index, event) in events.iter().enumerate() {
291            if event.ephemeral {
292                // WS-forward-only: fan out live with no store_seq, never persist.
293                let mut ephemeral = event.clone();
294                ephemeral.store_seq = None;
295                let send_result = self.live.send(ephemeral);
296                drop(send_result);
297                continue;
298            }
299            // Bound the event FIRST so the persisted record, the live fan-out,
300            // and every later replay all carry the same bounded shape. A single
301            // unrepresentable event is that event's failure, not the batch's.
302            let bounded = match bound_event(event, self.bounds.max_event_bytes) {
303                Ok(bounded) => bounded,
304                Err(error) => {
305                    outcomes[index] = EventOutcome::Refused;
306                    if first_error.is_none() {
307                        first_error = Some(error);
308                    }
309                    continue;
310                }
311            };
312            let key = ActivityStreamKey::of(&bounded);
313            match groups.iter_mut().find(|(existing, _)| *existing == key) {
314                Some((_, items)) => items.push((index, bounded)),
315                None => groups.push((key, vec![(index, bounded)])),
316            }
317        }
318        for (key, items) in groups {
319            if let Err(error) = self.persist_group(&key, &items, &mut outcomes).await
320                && first_error.is_none()
321            {
322                first_error = Some(error);
323            }
324        }
325        (outcomes, first_error)
326    }
327
328    /// Commit one stream's share of a batch, honouring the retention cap and the
329    /// optimistic-concurrency retry budget.
330    ///
331    /// `items` are `(index into the caller's batch, bounded event)` pairs in
332    /// arrival order, all for `key`; each event's outcome is written back into
333    /// `outcomes` at its index — its assigned `store_seq` when persisted,
334    /// [`EventOutcome::Refused`] when the store would not take it. A batch that
335    /// would cross the per-stream cap is split at the cap: the events below it
336    /// commit, the cap marker is written at the cap sequence, and the remainder
337    /// is fanned out live-only — exactly the sequence of states the single-event
338    /// path produced.
339    async fn persist_group(
340        &self,
341        key: &ActivityStreamKey,
342        items: &[(usize, ActivityEvent)],
343        outcomes: &mut [EventOutcome],
344    ) -> Result<(), StoreError> {
345        // Seed the optimistic-concurrency loop from the durable head. On a
346        // SequenceConflict a concurrent writer advanced the head between our read
347        // and our append, so we re-read the (now advanced) head and retry — this
348        // is what keeps store_seq strictly monotonic across racing writers.
349        let mut expected_seq = match self.store.activity_head(key).await {
350            Ok(head) => head,
351            Err(error) => {
352                mark_refused(items, outcomes, 0);
353                return Err(error);
354            }
355        };
356        let mut committed = 0usize;
357        let mut conflicts = 0usize;
358        while committed < items.len() {
359            // The per-stream retention cap is re-evaluated every iteration: a
360            // conflict advances `expected_seq`, which can cross the cap.
361            if expected_seq > self.bounds.max_stream_events {
362                // Past the cap (the marker at the cap seq is already durable):
363                // live streaming continues, persistence stops.
364                for (_, event) in &items[committed..] {
365                    self.fan_out_live_only(event);
366                }
367                return Ok(());
368            }
369            if expected_seq == self.bounds.max_stream_events {
370                match self
371                    .append_cap_marker(&items[committed].1, expected_seq)
372                    .await
373                {
374                    Ok(()) => {
375                        // The marker is durable; the triggering events themselves
376                        // are live-only, like everything after them.
377                        for (_, event) in &items[committed..] {
378                            self.fan_out_live_only(event);
379                        }
380                        return Ok(());
381                    }
382                    Err(StoreError::SequenceConflict { found, .. }) => {
383                        // A concurrent writer won the cap seq: adopt the head
384                        // and re-loop (the cap re-check then routes to drop).
385                        expected_seq = found;
386                        conflicts += 1;
387                    }
388                    Err(error) => {
389                        mark_refused(items, outcomes, committed);
390                        return Err(error);
391                    }
392                }
393                if conflicts >= MAX_SEQUENCE_CONFLICT_RETRIES {
394                    break;
395                }
396                continue;
397            }
398            // Commit only what fits below the cap; the remainder re-enters the
399            // loop at `expected_seq == cap` and takes the marker arm.
400            let room = usize::try_from(self.bounds.max_stream_events - expected_seq)
401                .unwrap_or(usize::MAX)
402                .min(items.len() - committed);
403            let batch: Vec<ActivityEvent> = items[committed..committed + room]
404                .iter()
405                .map(|(_, event)| event.clone())
406                .collect();
407            match self
408                .store
409                .append_activity_events(expected_seq, &batch)
410                .await
411            {
412                Ok(_new_head) => {
413                    for (offset, (index, event)) in
414                        items[committed..committed + room].iter().enumerate()
415                    {
416                        let store_seq =
417                            expected_seq.saturating_add(u64::try_from(offset).unwrap_or(u64::MAX));
418                        outcomes[*index] = EventOutcome::Persisted(store_seq);
419                        let mut persisted = event.clone();
420                        persisted.store_seq = Some(store_seq);
421                        let send_result = self.live.send(persisted);
422                        drop(send_result);
423                    }
424                    committed += room;
425                    expected_seq =
426                        expected_seq.saturating_add(u64::try_from(room).unwrap_or(u64::MAX));
427                }
428                Err(StoreError::SequenceConflict { found, .. }) => {
429                    // The durable head advanced past our expectation: adopt the
430                    // observed head and retry this batch there. Nothing of the
431                    // batch was written (all-or-nothing store contract).
432                    expected_seq = found;
433                    conflicts += 1;
434                    if conflicts >= MAX_SEQUENCE_CONFLICT_RETRIES {
435                        break;
436                    }
437                }
438                Err(error) => {
439                    mark_refused(items, outcomes, committed);
440                    return Err(error);
441                }
442            }
443        }
444        if committed < items.len() {
445            mark_refused(items, outcomes, committed);
446            return Err(StoreError::Backend(format!(
447                "observability append exceeded {MAX_SEQUENCE_CONFLICT_RETRIES} sequence-conflict retries for {key:?}"
448            )));
449        }
450        Ok(())
451    }
452
453    /// Drain `receiver` into the durable transcript in COALESCED batches until
454    /// the seam closes, returning the number of events the store refused.
455    ///
456    /// This is the one transcript drain: the liminal observability tap and the
457    /// declared-command executor both run their queue through it, so both pay
458    /// one commit per batch rather than one per event. Each iteration takes up
459    /// to [`TranscriptBatchPolicy::max_batch_events`] events off the queue,
460    /// waits at most [`TranscriptBatchPolicy::max_hold`] for a partial batch to
461    /// fill (skipping the wait entirely when the hold is zero or the batch is
462    /// already full), and commits the batch with [`Self::publish_all`].
463    ///
464    /// Returns when the sender is dropped AND the queue is empty, so a caller
465    /// can await this and know every queued event has been offered to the
466    /// sequencer.
467    ///
468    /// # Losing a transcript never fails the producer
469    ///
470    /// A store that refuses a batch costs a log line and that batch's events,
471    /// never the activity or the command: the refusal is warned once per FAILED
472    /// BATCH (bounded by the flush policy — never one log line per line of
473    /// output) naming `operation` and the batch's first event, and the total is
474    /// returned for the caller's end-of-run summary.
475    pub(crate) async fn drain<R: TranscriptEventReceiver>(
476        &self,
477        receiver: &mut R,
478        operation: &'static str,
479    ) -> u64 {
480        let limit = self.batch.max_batch_events.get();
481        let mut buffer: Vec<ActivityEvent> = Vec::with_capacity(limit);
482        let mut dropped: u64 = 0;
483        loop {
484            let mut closed = receiver.recv_many(&mut buffer, limit).await == 0;
485            if !closed && buffer.len() < limit && !self.batch.max_hold.is_zero() {
486                // Hold the partial batch open briefly so events arriving in the
487                // same burst share one commit. The deadline is absolute, so a
488                // slow trickle cannot extend the window indefinitely.
489                let deadline = tokio::time::Instant::now() + self.batch.max_hold;
490                while buffer.len() < limit {
491                    let remaining = limit - buffer.len();
492                    match tokio::time::timeout_at(
493                        deadline,
494                        receiver.recv_many(&mut buffer, remaining),
495                    )
496                    .await
497                    {
498                        Ok(0) => {
499                            closed = true;
500                            break;
501                        }
502                        Ok(_) => {}
503                        Err(_elapsed) => break,
504                    }
505                }
506            }
507            if !buffer.is_empty() {
508                let (outcomes, error) = self.publish_all_outcomes(&buffer).await;
509                if let Some(error) = error {
510                    let refused = outcomes
511                        .iter()
512                        .filter(|outcome| matches!(outcome, EventOutcome::Refused))
513                        .count();
514                    dropped = dropped.saturating_add(u64::try_from(refused).unwrap_or(u64::MAX));
515                    let first = buffer.first();
516                    tracing::warn!(
517                        %error,
518                        operation,
519                        batch_events = buffer.len(),
520                        refused_events = refused,
521                        workflow_id = ?first.map(|event| event.workflow_id.to_string()),
522                        activity_id = ?first.map(|event| event.activity_id.to_string()),
523                        attempt = ?first.map(|event| event.attempt),
524                        "transcript drain: the sequencer refused part of a batch; the producer \
525                         is unaffected and the refused events are not retained"
526                    );
527                }
528                buffer.clear();
529            }
530            if closed {
531                return dropped;
532            }
533        }
534    }
535
536    /// Fan one non-ephemeral event out live WITHOUT a `store_seq` (past-cap
537    /// delivery: the event is real transcript, just not retained).
538    fn fan_out_live_only(&self, event: &ActivityEvent) {
539        let mut live_only = event.clone();
540        live_only.store_seq = None;
541        let send_result = self.live.send(live_only);
542        drop(send_result);
543    }
544
545    /// Durably append the one retention-cap marker record at `cap_seq` (the
546    /// stream's `max_stream_events` position) and fan it out with its
547    /// `store_seq`. The marker carries the SAME identity fields as the event
548    /// that crossed the cap, so it lands in the same stream and attributes to
549    /// the same agent.
550    async fn append_cap_marker(
551        &self,
552        event: &ActivityEvent,
553        cap_seq: u64,
554    ) -> Result<(), StoreError> {
555        let cap = self.bounds.max_stream_events;
556        let mut marker = event.clone();
557        marker.kind = ActivityEventKind::Progress {
558            detail: ProgressDetail::Note {
559                text: format!(
560                    "transcript retention cap reached ({cap} events); further events are live-only and not persisted"
561                ),
562            },
563        };
564        let store_seq = self.store.append_activity_event(cap_seq, &marker).await?;
565        marker.store_seq = Some(store_seq);
566        let send_result = self.live.send(marker);
567        drop(send_result);
568        Ok(())
569    }
570
571    /// Read the durable `O` tail for `key` with `store_seq >= from_seq`.
572    ///
573    /// The priming read a resuming transcript client replays before splicing onto
574    /// the live stream. `from_seq = 0` replays the whole persisted transcript.
575    ///
576    /// # Errors
577    /// A [`StoreError`] from the durable read.
578    pub async fn replay_from(
579        &self,
580        key: &ActivityStreamKey,
581        from_seq: u64,
582    ) -> Result<Vec<ActivityRecord>, StoreError> {
583        self.store.read_activity_events_from(key, from_seq).await
584    }
585
586    /// Enumerate the retained transcript streams of ONE RUN of `workflow_id`
587    /// from the durable `O` keyspace (empty for a run with none — old runs
588    /// simply have no retained transcript).
589    ///
590    /// The run is required, never an optional filter: a workflow-wide
591    /// enumeration over a continue-as-new chain would list several generations'
592    /// streams under coordinates that collide pairwise, and the caller could
593    /// not tell them apart.
594    ///
595    /// # Errors
596    /// A [`StoreError`] from the durable enumeration.
597    pub async fn list_streams(
598        &self,
599        workflow_id: &aion_core::WorkflowId,
600        run_id: &aion_core::RunId,
601    ) -> Result<Vec<aion_store::ActivityStreamSummary>, StoreError> {
602        self.store.list_activity_streams(workflow_id, run_id).await
603    }
604
605    /// Subscribe to the live transcript tail for `key`, suppressing every event
606    /// for a DIFFERENT stream and every persisted event already covered by the
607    /// resume cursor.
608    ///
609    /// The broadcast is deployment-wide (one channel), so this filters to `key`'s
610    /// `(workflow, run, activity, attempt)` stream — an event from a sibling
611    /// continue-as-new generation of the same workflow fails the key comparison
612    /// and is suppressed, exactly like a different attempt's.
613    ///
614    /// `after_seq` dedups the splice seam
615    /// exactly like the cluster channel: attach this receiver BEFORE reading the
616    /// priming [`Self::replay_from`] tail, so an event that races the priming read
617    /// is retained by the receiver and applied after it (deduped on `store_seq`).
618    ///
619    /// The cursor is an `Option` because `store_seq` is **0-based** (the first
620    /// event is `store_seq == 0`): `after_seq = None` is a FRESH subscriber that
621    /// has applied nothing and must see every event including `store_seq == 0`;
622    /// `after_seq = Some(n)` has already applied through `store_seq == n`, so
623    /// events with `store_seq <= n` are suppressed at the seam. Ephemeral events
624    /// (which carry `store_seq: None`) for `key` are ALWAYS forwarded live — they
625    /// have no sequence to dedup and are never replayed.
626    #[must_use]
627    pub fn subscribe(
628        &self,
629        key: ActivityStreamKey,
630        after_seq: Option<u64>,
631    ) -> BoxStream<'static, Result<ActivityEvent, TranscriptStreamLagged>> {
632        let receiver = self.live.subscribe();
633        Box::pin(stream::unfold(
634            (receiver, key, after_seq),
635            |(mut receiver, key, after_seq)| async move {
636                loop {
637                    match receiver.recv().await {
638                        Ok(event) => {
639                            if ActivityStreamKey::of(&event) != key {
640                                // A different stream's event on the shared
641                                // broadcast — another attempt, or another
642                                // generation of this same workflow: not for
643                                // this subscriber.
644                                continue;
645                            }
646                            match (event.store_seq, after_seq) {
647                                // Already-applied persisted event at the splice
648                                // seam: suppress it (fall through to re-loop).
649                                (Some(seq), Some(cursor)) if seq <= cursor => {}
650                                // A live persisted event past the cursor, a fresh
651                                // subscriber (no cursor), or an ephemeral (None)
652                                // event: forward it.
653                                _ => return Some((Ok(event), (receiver, key, after_seq))),
654                            }
655                        }
656                        Err(broadcast::error::RecvError::Lagged(skipped)) => {
657                            return Some((
658                                Err(TranscriptStreamLagged { skipped }),
659                                (receiver, key, after_seq),
660                            ));
661                        }
662                        Err(broadcast::error::RecvError::Closed) => return None,
663                    }
664                }
665            },
666        ))
667    }
668}
669
670/// What became of one event handed to [`ActivityEventPublisher::publish_all`].
671///
672/// Distinguishing "not persisted by design" from "the store refused it" is what
673/// lets the drain report an EXACT dropped count instead of an upper bound: an
674/// ephemeral or past-the-retention-cap event is `NotPersisted` and is not a
675/// loss, while a `Refused` event is transcript the operator asked for and did
676/// not get.
677#[derive(Clone, Copy, Debug, PartialEq, Eq)]
678enum EventOutcome {
679    /// Deliberately not written: an ephemeral event, or one past the stream's
680    /// retention cap. Live fan-out still happened.
681    NotPersisted,
682    /// Durably committed at this `store_seq`.
683    Persisted(u64),
684    /// The store would not take it — the only outcome that counts as a loss.
685    Refused,
686}
687
688/// Mark every item of a group from `from` onwards as refused by the store.
689fn mark_refused(items: &[(usize, ActivityEvent)], outcomes: &mut [EventOutcome], from: usize) {
690    for (index, _) in &items[from..] {
691        outcomes[*index] = EventOutcome::Refused;
692    }
693}
694
695/// A queue [`ActivityEventPublisher::drain`] can pull whole batches from.
696///
697/// Exists so the ONE drain implementation serves both transcript seams — the
698/// liminal observability tap's bounded channel and the declared-command
699/// executor's unbounded one — without either growing its own copy of the
700/// coalescing loop. Both tokio receivers already offer the batch primitive;
701/// this trait is the two-line adapter that lets one loop take either.
702#[async_trait::async_trait]
703pub(crate) trait TranscriptEventReceiver: Send {
704    /// Move up to `limit` currently-queued events into `buffer`, awaiting the
705    /// first one, and return how many were moved. Returns `0` ONLY when the
706    /// channel is closed and drained (`limit` is never zero here).
707    async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize;
708}
709
710#[async_trait::async_trait]
711impl TranscriptEventReceiver for mpsc::Receiver<ActivityEvent> {
712    async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize {
713        Self::recv_many(self, buffer, limit).await
714    }
715}
716
717#[async_trait::async_trait]
718impl TranscriptEventReceiver for mpsc::UnboundedReceiver<ActivityEvent> {
719    async fn recv_many(&mut self, buffer: &mut Vec<ActivityEvent>, limit: usize) -> usize {
720        Self::recv_many(self, buffer, limit).await
721    }
722}
723
724#[cfg(test)]
725#[path = "activity_publisher_tests.rs"]
726mod tests;
727
728#[cfg(test)]
729#[path = "activity_publisher_batching_tests.rs"]
730mod batching_tests;