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}