harn_vm/run_events.rs
1//! Run-event sink: an execution-scoped bus the CLI attaches to capture every
2//! observable side effect of a `harn run` invocation as a single
3//! ordered stream.
4//!
5//! Concrete sinks live in `harn-cli` (`harn run --json` writes them as
6//! NDJSON). The VM only knows it should call [`emit`] from a handful of
7//! observability checkpoints; sinks fan-out from there.
8//!
9//! Variants intentionally mirror the surface area of the run command
10//! rather than the on-disk event log. Stdout/stderr writes are captured
11//! here because they never enter the event log; transcript / persona /
12//! hook / tool events are forwarded here in addition to their existing
13//! persistent topics so a single subscriber can see the whole run
14//! without joining across topics.
15
16use std::cell::RefCell;
17use std::future::Future;
18use std::sync::Arc;
19
20use serde::Serialize;
21
22/// One observable event from a running pipeline. Variants are
23/// `#[serde(tag = "event_type")]` so wire consumers (notably the CLI
24/// `--json` NDJSON stream) can discriminate without inspecting the
25/// payload shape.
26#[derive(Clone, Debug, Serialize)]
27#[serde(tag = "event_type", rename_all = "snake_case")]
28pub enum RunEvent {
29 /// Bytes written to stdout (raw, including any trailing newlines).
30 Stdout { payload: String },
31 /// Bytes written to stderr (raw, including any trailing newlines).
32 Stderr { payload: String },
33 /// One append on a transcript stream (`agent.transcript.llm` topic).
34 /// `kind` mirrors the transcript entry's `type` field.
35 Transcript {
36 #[serde(skip_serializing_if = "Option::is_none")]
37 agent_id: Option<String>,
38 kind: String,
39 payload: serde_json::Value,
40 },
41 /// A model-issued tool call. `call_id` matches the transcript
42 /// `call_id`; agents reconcile [`Self::ToolCall`] /
43 /// [`Self::ToolResult`] pairs by it.
44 ToolCall {
45 call_id: String,
46 name: String,
47 args: serde_json::Value,
48 /// RFC 3339 timestamp captured at emission.
49 started_at: String,
50 },
51 /// Outcome of a tool call.
52 ToolResult {
53 call_id: String,
54 ok: bool,
55 result: serde_json::Value,
56 },
57 /// A workflow hook fired during the run.
58 Hook {
59 name: String,
60 phase: String,
61 #[serde(skip_serializing_if = "serde_json::Value::is_null")]
62 payload: serde_json::Value,
63 },
64 /// A persona-stage transition. Mirrors the `persona.runtime.events`
65 /// topic; the `transition` field captures the stage state change
66 /// (`"started"`, `"completed"`, `"handoff"`, ...).
67 PersonaStage {
68 persona: String,
69 stage: String,
70 transition: String,
71 },
72 /// `harn run <bundle.harnpack>` resolved a pack to execute. Carries
73 /// the verified bundle hash, whether the embedded Ed25519 signature
74 /// verified end-to-end, the signing key fingerprint (when signed),
75 /// and whether the unpacked archive came from the content-addressed
76 /// cache or was extracted fresh on this run.
77 PackRun {
78 bundle_hash: String,
79 signature_verified: bool,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 key_id: Option<String>,
82 cache_hit: bool,
83 dry_run_verify: bool,
84 execution_artifact_state: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 fallback_reason: Option<String>,
87 artifact_decode_ms: u64,
88 },
89}
90
91/// Receiver of [`RunEvent`]s. Implementations must be cheap (the VM
92/// calls [`emit`] on hot paths like every `println`).
93pub trait RunEventSink: Send + Sync {
94 fn emit(&self, event: RunEvent);
95}
96
97thread_local! {
98 /// The sink for the execution currently being polled on this thread.
99 ///
100 /// `AmbientExecutionScope` swaps this slot at every task poll, so the
101 /// thread-local is execution-owned even when unrelated futures interleave
102 /// on one runtime thread.
103 static RUN_EVENT_SINK_CONTEXT: RefCell<Option<Arc<dyn RunEventSink>>> =
104 RefCell::new(None);
105}
106
107pub(crate) fn swap_run_event_sink(
108 sink: Option<Arc<dyn RunEventSink>>,
109) -> Option<Arc<dyn RunEventSink>> {
110 RUN_EVENT_SINK_CONTEXT.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), sink))
111}
112
113/// Run `inner` with `sink` attached to its ambient execution scope.
114///
115/// The scope is installed only while `inner` is being polled. Inline and
116/// spawned VM work that captures the ambient execution inherits the sink;
117/// unrelated or sibling executions do not.
118pub fn scope<F: Future>(sink: Arc<dyn RunEventSink>, inner: F) -> impl Future<Output = F::Output> {
119 crate::orchestration::scope_run_event_sink(sink, inner)
120}
121
122/// Whether the current execution has a sink. Useful as a fast-path gate
123/// for callers that would otherwise build a payload speculatively.
124pub fn sink_active() -> bool {
125 RUN_EVENT_SINK_CONTEXT.with(|slot| slot.borrow().is_some())
126}
127
128/// Emit `event` to the current execution's sink. No-op when no sink is active,
129/// so it is safe to call from every hook point unconditionally.
130pub fn emit(event: RunEvent) {
131 let sink = RUN_EVENT_SINK_CONTEXT.with(|slot| slot.borrow().clone());
132 if let Some(sink) = sink {
133 sink.emit(redact_run_event(event));
134 }
135}
136
137/// Scrub the secret-bearing JSON field of a `RunEvent` once, centrally, before
138/// it reaches any sink — so emitters can't forget (the `Hook` payload did, while
139/// the transcript/tool variants were only clean because `agent_observe`
140/// pre-scrubbed them). A second idempotent pass over a pre-scrubbed payload is
141/// cheap and makes the bus correct-by-construction for every future variant.
142/// `Stdout`/`Stderr` are the program's own raw output and pass through
143/// unredacted; they also take the fast path (no policy lookup) since `emit` runs
144/// on every `println`.
145fn redact_run_event(mut event: RunEvent) -> RunEvent {
146 let payload = match &mut event {
147 RunEvent::Transcript { payload, .. } => payload,
148 RunEvent::ToolCall { args, .. } => args,
149 RunEvent::ToolResult { result, .. } => result,
150 RunEvent::Hook { payload, .. } => payload,
151 RunEvent::Stdout { .. }
152 | RunEvent::Stderr { .. }
153 | RunEvent::PersonaStage { .. }
154 | RunEvent::PackRun { .. } => return event,
155 };
156 crate::redact::current_policy().redact_json_in_place(payload);
157 event
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use std::sync::Mutex;
164
165 struct CapturingSink {
166 events: Mutex<Vec<RunEvent>>,
167 }
168
169 impl RunEventSink for CapturingSink {
170 fn emit(&self, event: RunEvent) {
171 self.events.lock().unwrap().push(event);
172 }
173 }
174
175 #[tokio::test]
176 async fn nested_scopes_restore_the_outer_sink() {
177 let outer = Arc::new(CapturingSink {
178 events: Mutex::new(Vec::new()),
179 });
180 let inner = Arc::new(CapturingSink {
181 events: Mutex::new(Vec::new()),
182 });
183
184 scope(outer.clone(), async {
185 emit(RunEvent::Stdout {
186 payload: "outer-before\n".into(),
187 });
188 crate::orchestration::scope_inline_subtask(async {
189 emit(RunEvent::Stdout {
190 payload: "outer-inline\n".into(),
191 });
192 })
193 .await;
194 let inherited = crate::orchestration::AmbientExecutionScope::capture_inherited();
195 crate::orchestration::scope_ambient(inherited, async {
196 emit(RunEvent::Stdout {
197 payload: "outer-worker\n".into(),
198 });
199 })
200 .await;
201 scope(inner.clone(), async {
202 emit(RunEvent::Stdout {
203 payload: "inner\n".into(),
204 });
205 })
206 .await;
207 emit(RunEvent::Stdout {
208 payload: "outer-after\n".into(),
209 });
210 })
211 .await;
212
213 assert!(!sink_active());
214 emit(RunEvent::Stdout {
215 payload: "unscoped\n".into(),
216 });
217
218 let payloads = |sink: &CapturingSink| {
219 sink.events
220 .lock()
221 .unwrap()
222 .iter()
223 .map(|event| match event {
224 RunEvent::Stdout { payload } => payload.clone(),
225 other => panic!("unexpected event {other:?}"),
226 })
227 .collect::<Vec<_>>()
228 };
229 assert_eq!(
230 payloads(&outer),
231 [
232 "outer-before\n",
233 "outer-inline\n",
234 "outer-worker\n",
235 "outer-after\n"
236 ]
237 );
238 assert_eq!(payloads(&inner), ["inner\n"]);
239 }
240}