Skip to main content

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}