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}