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. `agent_id` is the nil UUID: a declared command
22//! is not an agent and has no sub-identity to attribute, and nil is what this
23//! codebase already writes where agent attribution is absent.
24//!
25//! # Size
26//!
27//! No byte ceiling is applied here, deliberately. The server's transcript
28//! publisher bounds every event it persists against the operator's
29//! `observability.max_event_bytes`, truncating on a `char` boundary with an
30//! explicit marker; a second ceiling on this side would be the same rule known
31//! in two places, free to drift from the one that is actually enforced. Nor does
32//! streaming add an unbounded buffer: a line is a borrowed slice of the capture
33//! the command's completion already retains in full, and the event carries only
34//! that one line.
35
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37
38use aion_core::{ActivityEvent, ActivityEventKind, MessageRole};
39use chrono::Utc;
40use uuid::Uuid;
41
42use crate::context::ActivityContext;
43use crate::process::{CommandOutputObserver, CommandStream};
44
45/// The producer label carried by a line the command wrote to standard output.
46const STDOUT_PRODUCER: &str = "command stdout";
47
48/// The producer label carried by a line the command wrote to standard error.
49const STDERR_PRODUCER: &str = "command stderr";
50
51/// The producer label for one of a command's output streams.
52const fn producer_label(stream: CommandStream) -> &'static str {
53    match stream {
54        CommandStream::Stdout => STDOUT_PRODUCER,
55        CommandStream::Stderr => STDERR_PRODUCER,
56    }
57}
58
59/// Publishes a contained command's output lines onto an activity's transcript.
60///
61/// Built for one command execution and passed to
62/// [`run_cancellable_command`](crate::process::run_cancellable_command) as its
63/// observer. On a context with no live transcript seam — every isolated unit
64/// test, and any activity whose host installed none — emitting is the documented
65/// no-op that [`ActivityContext::emit_event`] already is, so a command runs
66/// exactly as it did before.
67#[derive(Debug)]
68pub struct CommandTranscript<'context> {
69    context: &'context ActivityContext,
70    /// Worker-local monotonic sequence, shared across both streams so the
71    /// numbering follows the order lines were actually observed.
72    next_seq: AtomicU64,
73    /// Whether the seam-closed diagnostic has already been emitted for this
74    /// command, so a closed seam costs one log line and not one per line of
75    /// output.
76    seam_failure_reported: AtomicBool,
77}
78
79impl<'context> CommandTranscript<'context> {
80    /// Build a transcript publisher for the command `context` is executing.
81    #[must_use]
82    pub fn new(context: &'context ActivityContext) -> Self {
83        Self {
84            context,
85            next_seq: AtomicU64::new(0),
86            seam_failure_reported: AtomicBool::new(false),
87        }
88    }
89
90    /// The transcript event one observed line becomes. The full stream key —
91    /// `(workflow, run, activity, attempt)` — is read off the context, which
92    /// carries it by construction; a line is never attributed to an invented or
93    /// partial identity.
94    fn event(&self, stream: CommandStream, line: &str) -> ActivityEvent {
95        ActivityEvent {
96            workflow_id: self.context.workflow_id().clone(),
97            run_id: self.context.run_id().clone(),
98            activity_id: self.context.activity_id().clone(),
99            attempt: self.context.attempt(),
100            agent_id: Uuid::nil(),
101            agent_role: producer_label(stream).to_owned(),
102            // Producer-clock emission time, exactly as a harness adapter stamps
103            // it. This is an observability record and never enters replay, so it
104            // is not a determinism-boundary clock read.
105            emitted_at: Utc::now(),
106            worker_seq: self.next_seq.fetch_add(1, Ordering::Relaxed),
107            // Assigned by the server's sequencer at durable-commit time; a
108            // producer never mints one.
109            store_seq: None,
110            // Command output is retained transcript, not a token delta.
111            ephemeral: false,
112            kind: ActivityEventKind::Message {
113                role: MessageRole::Tool,
114                text: line.to_owned(),
115            },
116        }
117    }
118}
119
120impl CommandOutputObserver for CommandTranscript<'_> {
121    fn on_line(&self, stream: CommandStream, line: &str) {
122        let event = self.event(stream, line);
123        if let Err(error) = self.context.emit_event(event) {
124            // The seam closed mid-command (its drain was dropped). The command
125            // keeps running and its result is unaffected; the loss is named once
126            // rather than once per line.
127            if !self.seam_failure_reported.swap(true, Ordering::Relaxed) {
128                tracing::warn!(
129                    %error,
130                    stream = stream.name(),
131                    activity_id = %self.context.activity_id(),
132                    attempt = self.context.attempt(),
133                    "command transcript: the activity's event seam is closed; this command's \
134                     remaining output is not streamed to the transcript"
135                );
136            }
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use aion_core::{ActivityEventKind, ActivityId, MessageRole, RunId, WorkflowId};
144    use tokio::sync::mpsc;
145    use uuid::Uuid;
146
147    use super::{CommandTranscript, STDERR_PRODUCER, STDOUT_PRODUCER};
148    use crate::context::ActivityContext;
149    use crate::process::{CommandOutputObserver, CommandStream};
150
151    /// What a test returns. Every fallible step is carried rather than
152    /// unwrapped, because the workspace denies panicking accessors in test code
153    /// as firmly as in library code.
154    type TestResult = Result<(), Box<dyn std::error::Error>>;
155
156    fn workflow_id() -> WorkflowId {
157        WorkflowId::new(Uuid::from_u128(0x5eed))
158    }
159
160    fn run_id() -> RunId {
161        RunId::new(Uuid::from_u128(0x5eed_0001))
162    }
163
164    /// The whole event contract for one observed line, asserted field by field:
165    /// the stream key comes from the context, the line is a `Tool` message, the
166    /// producing stream is named, and the sequence is the producer's own.
167    #[tokio::test]
168    async fn an_observed_line_becomes_a_transcript_message_keyed_to_the_activity() -> TestResult {
169        let (sender, mut events) = mpsc::unbounded_channel();
170        let (context, cancellation) = ActivityContext::with_transcript(
171            workflow_id(),
172            run_id(),
173            ActivityId::from_sequence_position(4),
174            2,
175            sender,
176        );
177        drop(cancellation);
178        let transcript = CommandTranscript::new(&context);
179
180        transcript.on_line(CommandStream::Stdout, "building");
181
182        let event = events.recv().await.ok_or("the line must be emitted")?;
183        assert_eq!(event.workflow_id, workflow_id());
184        assert_eq!(event.run_id, run_id());
185        assert_eq!(event.activity_id, ActivityId::from_sequence_position(4));
186        assert_eq!(event.attempt, 2);
187        assert_eq!(event.agent_role, STDOUT_PRODUCER);
188        assert_eq!(event.agent_id, Uuid::nil());
189        assert_eq!(event.worker_seq, 0);
190        assert_eq!(event.store_seq, None);
191        assert!(
192            !event.ephemeral,
193            "command output is retained transcript, never a WS-only delta"
194        );
195        let ActivityEventKind::Message { role, text } = event.kind else {
196            return Err("an output line must be a Message".into());
197        };
198        assert_eq!(role, MessageRole::Tool);
199        assert_eq!(text, "building");
200        Ok(())
201    }
202
203    /// Both streams reach the same transcript, stay distinguishable by their
204    /// producer label, and share one monotonic sequence that follows arrival
205    /// order.
206    #[tokio::test]
207    async fn both_streams_are_labelled_and_share_one_monotonic_sequence() -> TestResult {
208        let (sender, mut events) = mpsc::unbounded_channel();
209        let (context, cancellation) = ActivityContext::with_transcript(
210            workflow_id(),
211            run_id(),
212            ActivityId::from_sequence_position(1),
213            0,
214            sender,
215        );
216        drop(cancellation);
217        let transcript = CommandTranscript::new(&context);
218
219        transcript.on_line(CommandStream::Stdout, "out");
220        transcript.on_line(CommandStream::Stderr, "err");
221        transcript.on_line(CommandStream::Stdout, "out again");
222
223        let mut observed = Vec::new();
224        for _ in 0..3u8 {
225            let event = events.recv().await.ok_or("every line must be emitted")?;
226            let ActivityEventKind::Message { text, .. } = event.kind else {
227                return Err("an output line must be a Message".into());
228            };
229            observed.push((event.agent_role, text, event.worker_seq));
230        }
231        assert_eq!(
232            observed,
233            vec![
234                (STDOUT_PRODUCER.to_owned(), "out".to_owned(), 0),
235                (STDERR_PRODUCER.to_owned(), "err".to_owned(), 1),
236                (STDOUT_PRODUCER.to_owned(), "out again".to_owned(), 2),
237            ]
238        );
239        Ok(())
240    }
241
242    /// A context with no transcript seam installed — the isolated unit-test
243    /// shape — publishes nothing and does not fail the command. Identity is
244    /// always present (it is required at construction); absence of the SEAM is
245    /// the only no-op condition left.
246    #[tokio::test]
247    async fn a_context_without_a_seam_publishes_nothing() {
248        let (context, cancellation) = ActivityContext::new(
249            workflow_id(),
250            run_id(),
251            ActivityId::from_sequence_position(1),
252            0,
253        );
254        drop(cancellation);
255        let transcript = CommandTranscript::new(&context);
256        transcript.on_line(CommandStream::Stdout, "ignored");
257    }
258
259    /// A seam whose receiving end has been dropped mid-command does not fail the
260    /// command: the remaining lines are dropped and the loss is reported, not
261    /// escalated.
262    #[tokio::test]
263    async fn a_closed_seam_does_not_fail_the_command() -> TestResult {
264        let (sender, 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        drop(events);
274        let transcript = CommandTranscript::new(&context);
275
276        transcript.on_line(CommandStream::Stdout, "into the void");
277        transcript.on_line(CommandStream::Stderr, "also lost");
278        Ok(())
279    }
280}