Skip to main content

aion_worker/
command_transcript.rs

1//! A contained command's output, streamed into the activity transcript.
2//!
3//! [`CommandTranscript`] is the bridge between the two halves this build joins:
4//! [`run_cancellable_command`](crate::process::run_cancellable_command) now
5//! delivers a command's output line by line as it is written, and
6//! [`ActivityContext::emit_event`] is the seam an activity publishes live
7//! transcript events on. Wiring the first into the second is what makes a
8//! command step readable WHILE it runs, through exactly the machinery an agent
9//! step already uses — the same [`ActivityEvent`] envelope, the same
10//! `(workflow, run, activity, attempt)` stream, the same durable append and
11//! cursor reads on the server side. Nothing downstream had to learn what a
12//! command is.
13//!
14//! # What one line becomes
15//!
16//! A line becomes a [`ActivityEventKind::Message`] attributed to
17//! [`MessageRole::Tool`] — a non-conversational participant's output, which is
18//! what a command's writing is. The stream it came from is carried by the
19//! event's `agent_role` (the envelope's producer label), so stdout and stderr
20//! stay distinguishable without inventing a persisted event kind for something
21//! the locked set already models. A declared BODY, which runs one process per
22//! line onto that one stream, says which line spoke in the same label —
23//! `command stdout (line 2 of 5)`, built by
24//! [`CommandTranscript::for_body_line`] — so a reader is never left guessing
25//! which of a body's programs wrote a line. `agent_id` is the nil UUID: a declared command
26//! is not an agent and has no sub-identity to attribute, and nil is what this
27//! codebase already writes where agent attribution is absent.
28//!
29//! # Size
30//!
31//! No byte ceiling is applied here, deliberately. The server's transcript
32//! publisher bounds every event it persists against the operator's
33//! `observability.max_event_bytes`, truncating on a `char` boundary with an
34//! explicit marker; a second ceiling on this side would be the same rule known
35//! in two places, free to drift from the one that is actually enforced. Nor does
36//! streaming add an unbounded buffer: a line is a borrowed slice of the capture
37//! the command's completion already retains in full, and the event carries only
38//! that one line.
39
40use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
41
42use aion_core::{ActivityEvent, ActivityEventKind, MessageRole};
43use chrono::Utc;
44use uuid::Uuid;
45
46use crate::context::ActivityContext;
47use crate::process::{CommandOutputObserver, CommandStream};
48
49/// The producer label carried by a line the command wrote to standard output.
50const STDOUT_PRODUCER: &str = "command stdout";
51
52/// The producer label carried by a line the command wrote to standard error.
53const STDERR_PRODUCER: &str = "command stderr";
54
55/// The producer label for one of a command's output streams.
56const fn producer_label(stream: CommandStream) -> &'static str {
57    match stream {
58        CommandStream::Stdout => STDOUT_PRODUCER,
59        CommandStream::Stderr => STDERR_PRODUCER,
60    }
61}
62
63/// Which line of a declared command's BODY is speaking.
64///
65/// A `run "…"` action is one process and needs none of this. A declared body is
66/// several, one per line, and every one of them publishes onto the same
67/// activity stream — so a reader handed five programs' output under one
68/// producer label is left guessing which program wrote what. The position is
69/// carried in the label rather than in a new event field because the locked
70/// event set already models the producer, and a second axis for the same fact
71/// is a fact free to disagree with itself.
72#[derive(Debug, Clone, Copy)]
73struct BodyLine {
74    /// The line's ONE-BASED position in the body.
75    position: usize,
76    /// How many lines the body states.
77    total: usize,
78}
79
80/// Publishes a contained command's output lines onto an activity's transcript.
81///
82/// Built for one command execution and passed to
83/// [`run_cancellable_command`](crate::process::run_cancellable_command) as its
84/// observer. On a context with no live transcript seam — every isolated unit
85/// test, and any activity whose host installed none — emitting is the documented
86/// no-op that [`ActivityContext::emit_event`] already is, so a command runs
87/// exactly as it did before.
88#[derive(Debug)]
89pub struct CommandTranscript<'context> {
90    context: &'context ActivityContext,
91    /// Which body line's output this publisher is carrying, when it is one
92    /// line of several. `None` for a command that is one process.
93    speaker: Option<BodyLine>,
94    /// Worker-local monotonic sequence, shared across both streams so the
95    /// numbering follows the order lines were actually observed.
96    next_seq: AtomicU64,
97    /// Whether the seam-closed diagnostic has already been emitted for this
98    /// command, so a closed seam costs one log line and not one per line of
99    /// output.
100    seam_failure_reported: AtomicBool,
101}
102
103impl<'context> CommandTranscript<'context> {
104    /// Build a transcript publisher for the command `context` is executing.
105    #[must_use]
106    pub fn new(context: &'context ActivityContext) -> Self {
107        Self {
108            context,
109            speaker: None,
110            next_seq: AtomicU64::new(0),
111            seam_failure_reported: AtomicBool::new(false),
112        }
113    }
114
115    /// Build a transcript publisher for ONE line of a declared command's body,
116    /// whose events say which line they came from: `command stdout (line 2 of
117    /// 5)`.
118    ///
119    /// `position` is one-based, counted the way the body's failures count it,
120    /// so a reader comparing a failure sentence against the transcript is
121    /// comparing the same numbers.
122    #[must_use]
123    pub fn for_body_line(
124        context: &'context ActivityContext,
125        position: usize,
126        total: usize,
127    ) -> Self {
128        Self {
129            context,
130            speaker: Some(BodyLine { position, total }),
131            next_seq: AtomicU64::new(0),
132            seam_failure_reported: AtomicBool::new(false),
133        }
134    }
135
136    /// The producer label an event carries: the stream, and which body line
137    /// spoke when the command has more than one.
138    fn producer(&self, stream: CommandStream) -> String {
139        let label = producer_label(stream);
140        self.speaker.map_or_else(
141            || label.to_owned(),
142            |BodyLine { position, total }| format!("{label} (line {position} of {total})"),
143        )
144    }
145
146    /// The transcript event one observed line becomes. The full stream key —
147    /// `(workflow, run, activity, attempt)` — is read off the context, which
148    /// carries it by construction; a line is never attributed to an invented or
149    /// partial identity.
150    fn event(&self, stream: CommandStream, line: &str) -> ActivityEvent {
151        ActivityEvent {
152            workflow_id: self.context.workflow_id().clone(),
153            run_id: self.context.run_id().clone(),
154            activity_id: self.context.activity_id().clone(),
155            attempt: self.context.attempt(),
156            agent_id: Uuid::nil(),
157            agent_role: self.producer(stream),
158            // Producer-clock emission time, exactly as a harness adapter stamps
159            // it. This is an observability record and never enters replay, so it
160            // is not a determinism-boundary clock read.
161            emitted_at: Utc::now(),
162            worker_seq: self.next_seq.fetch_add(1, Ordering::Relaxed),
163            // Assigned by the server's sequencer at durable-commit time; a
164            // producer never mints one.
165            store_seq: None,
166            // Command output is retained transcript, not a token delta.
167            ephemeral: false,
168            kind: ActivityEventKind::Message {
169                role: MessageRole::Tool,
170                text: line.to_owned(),
171            },
172        }
173    }
174}
175
176impl CommandOutputObserver for CommandTranscript<'_> {
177    fn on_line(&self, stream: CommandStream, line: &str) {
178        let event = self.event(stream, line);
179        if let Err(error) = self.context.emit_event(event) {
180            // The seam closed mid-command (its drain was dropped). The command
181            // keeps running and its result is unaffected; the loss is named once
182            // rather than once per line.
183            if !self.seam_failure_reported.swap(true, Ordering::Relaxed) {
184                tracing::warn!(
185                    %error,
186                    stream = stream.name(),
187                    activity_id = %self.context.activity_id(),
188                    attempt = self.context.attempt(),
189                    "command transcript: the activity's event seam is closed; this command's \
190                     remaining output is not streamed to the transcript"
191                );
192            }
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use aion_core::{ActivityEventKind, ActivityId, MessageRole, RunId, WorkflowId};
200    use tokio::sync::mpsc;
201    use uuid::Uuid;
202
203    use super::{CommandTranscript, STDERR_PRODUCER, STDOUT_PRODUCER};
204    use crate::context::ActivityContext;
205    use crate::process::{CommandOutputObserver, CommandStream};
206
207    /// What a test returns. Every fallible step is carried rather than
208    /// unwrapped, because the workspace denies panicking accessors in test code
209    /// as firmly as in library code.
210    type TestResult = Result<(), Box<dyn std::error::Error>>;
211
212    fn workflow_id() -> WorkflowId {
213        WorkflowId::new(Uuid::from_u128(0x5eed))
214    }
215
216    fn run_id() -> RunId {
217        RunId::new(Uuid::from_u128(0x5eed_0001))
218    }
219
220    /// The whole event contract for one observed line, asserted field by field:
221    /// the stream key comes from the context, the line is a `Tool` message, the
222    /// producing stream is named, and the sequence is the producer's own.
223    #[tokio::test]
224    async fn an_observed_line_becomes_a_transcript_message_keyed_to_the_activity() -> TestResult {
225        let (sender, mut events) = mpsc::unbounded_channel();
226        let (context, cancellation) = ActivityContext::with_transcript(
227            workflow_id(),
228            run_id(),
229            ActivityId::from_sequence_position(4),
230            2,
231            sender,
232        );
233        drop(cancellation);
234        let transcript = CommandTranscript::new(&context);
235
236        transcript.on_line(CommandStream::Stdout, "building");
237
238        let event = events.recv().await.ok_or("the line must be emitted")?;
239        assert_eq!(event.workflow_id, workflow_id());
240        assert_eq!(event.run_id, run_id());
241        assert_eq!(event.activity_id, ActivityId::from_sequence_position(4));
242        assert_eq!(event.attempt, 2);
243        assert_eq!(event.agent_role, STDOUT_PRODUCER);
244        assert_eq!(event.agent_id, Uuid::nil());
245        assert_eq!(event.worker_seq, 0);
246        assert_eq!(event.store_seq, None);
247        assert!(
248            !event.ephemeral,
249            "command output is retained transcript, never a WS-only delta"
250        );
251        let ActivityEventKind::Message { role, text } = event.kind else {
252            return Err("an output line must be a Message".into());
253        };
254        assert_eq!(role, MessageRole::Tool);
255        assert_eq!(text, "building");
256        Ok(())
257    }
258
259    /// Both streams reach the same transcript, stay distinguishable by their
260    /// producer label, and share one monotonic sequence that follows arrival
261    /// order.
262    #[tokio::test]
263    async fn both_streams_are_labelled_and_share_one_monotonic_sequence() -> TestResult {
264        let (sender, mut events) = mpsc::unbounded_channel();
265        let (context, cancellation) = ActivityContext::with_transcript(
266            workflow_id(),
267            run_id(),
268            ActivityId::from_sequence_position(1),
269            0,
270            sender,
271        );
272        drop(cancellation);
273        let transcript = CommandTranscript::new(&context);
274
275        transcript.on_line(CommandStream::Stdout, "out");
276        transcript.on_line(CommandStream::Stderr, "err");
277        transcript.on_line(CommandStream::Stdout, "out again");
278
279        let mut observed = Vec::new();
280        for _ in 0..3u8 {
281            let event = events.recv().await.ok_or("every line must be emitted")?;
282            let ActivityEventKind::Message { text, .. } = event.kind else {
283                return Err("an output line must be a Message".into());
284            };
285            observed.push((event.agent_role, text, event.worker_seq));
286        }
287        assert_eq!(
288            observed,
289            vec![
290                (STDOUT_PRODUCER.to_owned(), "out".to_owned(), 0),
291                (STDERR_PRODUCER.to_owned(), "err".to_owned(), 1),
292                (STDOUT_PRODUCER.to_owned(), "out again".to_owned(), 2),
293            ]
294        );
295        Ok(())
296    }
297
298    /// A DECLARED BODY's line says which line it is, on both streams, so five
299    /// programs publishing onto one activity stream stay tellable apart. The
300    /// numbering is one-based and matches the numbering the body's failure
301    /// sentences use.
302    #[tokio::test]
303    async fn a_body_lines_output_says_which_line_of_how_many_wrote_it() -> TestResult {
304        let (sender, mut events) = mpsc::unbounded_channel();
305        let (context, cancellation) = ActivityContext::with_transcript(
306            workflow_id(),
307            run_id(),
308            ActivityId::from_sequence_position(9),
309            0,
310            sender,
311        );
312        drop(cancellation);
313        let transcript = CommandTranscript::for_body_line(&context, 2, 5);
314
315        transcript.on_line(CommandStream::Stdout, "out");
316        transcript.on_line(CommandStream::Stderr, "err");
317
318        let first = events.recv().await.ok_or("the line must be emitted")?;
319        let second = events.recv().await.ok_or("the line must be emitted")?;
320        assert_eq!(first.agent_role, "command stdout (line 2 of 5)");
321        assert_eq!(second.agent_role, "command stderr (line 2 of 5)");
322        Ok(())
323    }
324
325    /// A command that is ONE process keeps the bare label: there is no line to
326    /// name, and a "(line 1 of 1)" on every `run "…"` action would be noise
327    /// that teaches a reader to stop reading the label.
328    #[tokio::test]
329    async fn a_single_process_command_keeps_the_bare_stream_label() -> TestResult {
330        let (sender, mut events) = mpsc::unbounded_channel();
331        let (context, cancellation) = ActivityContext::with_transcript(
332            workflow_id(),
333            run_id(),
334            ActivityId::from_sequence_position(9),
335            0,
336            sender,
337        );
338        drop(cancellation);
339        let transcript = CommandTranscript::new(&context);
340
341        transcript.on_line(CommandStream::Stdout, "out");
342
343        let event = events.recv().await.ok_or("the line must be emitted")?;
344        assert_eq!(event.agent_role, STDOUT_PRODUCER);
345        Ok(())
346    }
347
348    /// A context with no transcript seam installed — the isolated unit-test
349    /// shape — publishes nothing and does not fail the command. Identity is
350    /// always present (it is required at construction); absence of the SEAM is
351    /// the only no-op condition left.
352    #[tokio::test]
353    async fn a_context_without_a_seam_publishes_nothing() {
354        let (context, cancellation) = ActivityContext::new(
355            workflow_id(),
356            run_id(),
357            ActivityId::from_sequence_position(1),
358            0,
359        );
360        drop(cancellation);
361        let transcript = CommandTranscript::new(&context);
362        transcript.on_line(CommandStream::Stdout, "ignored");
363    }
364
365    /// A seam whose receiving end has been dropped mid-command does not fail the
366    /// command: the remaining lines are dropped and the loss is reported, not
367    /// escalated.
368    #[tokio::test]
369    async fn a_closed_seam_does_not_fail_the_command() -> TestResult {
370        let (sender, events) = mpsc::unbounded_channel();
371        let (context, cancellation) = ActivityContext::with_transcript(
372            workflow_id(),
373            run_id(),
374            ActivityId::from_sequence_position(1),
375            0,
376            sender,
377        );
378        drop(cancellation);
379        drop(events);
380        let transcript = CommandTranscript::new(&context);
381
382        transcript.on_line(CommandStream::Stdout, "into the void");
383        transcript.on_line(CommandStream::Stderr, "also lost");
384        Ok(())
385    }
386}