use super::*;
pub(super) async fn list_events(
State(state): State<ApiState>,
Query(query): Query<ListQuery>,
) -> Response {
let filter = EventFilter::from_query(&query, None, None);
history_response(&state, &filter)
}
pub(super) async fn list_workflow_trigger_runs(
State(state): State<ApiState>,
Query(query): Query<ListQuery>,
) -> Response {
let Some(event_log) = state.event_log.clone() else {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"event_log_unavailable",
state
.event_log_error
.as_deref()
.unwrap_or("event log unavailable"),
);
};
let limit = query.limit.unwrap_or(100).clamp(1, 500);
let dispatches = match read_api_event_log_topic(&event_log, harn_vm::TRIGGER_OUTBOX_TOPIC).await
{
Ok(events) => events,
Err(message) => {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"event_log_read_failed",
&message,
);
}
};
let action_graph = match read_api_event_log_topic(&event_log, ACTION_GRAPH_TOPIC).await {
Ok(events) => events,
Err(message) => {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"event_log_read_failed",
&message,
);
}
};
Json(list_response(workflow_trigger_run_values(
&dispatches,
&action_graph,
limit,
)))
.into_response()
}
pub(super) async fn list_session_events(
State(state): State<ApiState>,
AxumPath(session_id): AxumPath<String>,
Query(query): Query<ListQuery>,
) -> Response {
let filter = EventFilter::from_query(&query, Some(session_id), None);
history_response(&state, &filter)
}
pub(super) async fn list_task_events(
State(state): State<ApiState>,
AxumPath(task_id): AxumPath<String>,
Query(query): Query<ListQuery>,
) -> Response {
let filter = EventFilter::from_query(&query, None, Some(task_id));
history_response(&state, &filter)
}
pub(super) async fn stream_events(
State(state): State<ApiState>,
Query(query): Query<ListQuery>,
headers: HeaderMap,
) -> Response {
stream_events_response(state, EventFilter::from_query(&query, None, None), headers).await
}
pub(super) async fn stream_session_events(
State(state): State<ApiState>,
AxumPath(session_id): AxumPath<String>,
Query(query): Query<ListQuery>,
headers: HeaderMap,
) -> Response {
stream_events_response(
state,
EventFilter::from_query(&query, Some(session_id), None),
headers,
)
.await
}
pub(super) async fn stream_task_events(
State(state): State<ApiState>,
AxumPath(task_id): AxumPath<String>,
Query(query): Query<ListQuery>,
headers: HeaderMap,
) -> Response {
stream_events_response(
state,
EventFilter::from_query(&query, None, Some(task_id)),
headers,
)
.await
}
fn cursor_error(message: &'static str) -> Response {
Sse::new(stream::iter([Ok::<_, Infallible>(
Event::default()
.event("error")
.data(json!({"error":{"code":"cursor_unavailable", "message":message}}).to_string()),
)]))
.into_response()
}
async fn stream_events_response(
state: ApiState,
mut filter: EventFilter,
headers: HeaderMap,
) -> Response {
let mut values = headers.get_all("last-event-id").iter();
if let Some(value) = values.next() {
let Ok(cursor) = value.to_str() else {
return cursor_error("Last-Event-ID must be a valid event cursor");
};
if values.next().is_some()
|| cursor.is_empty()
|| filter.after.as_deref().is_some_and(|after| after != cursor)
{
return cursor_error("event cursors are empty, duplicated, or disagree");
}
filter.after = Some(cursor.to_owned());
}
let rx = state.events_tx.subscribe();
let cursor = filter.after.take();
let history = match state.history(&filter) {
Ok(history) => history,
Err(_) => return task_store::unavailable_response(),
};
if cursor
.as_ref()
.is_some_and(|cursor| !history.iter().any(|event| &event.id == cursor))
{
return cursor_error("event cursor is unknown or no longer available in this stream");
}
filter.after = cursor;
let history: Vec<_> = history
.into_iter()
.filter(|event| filter.matches(event))
.collect();
let replayed = history.iter().map(|event| event.id.clone()).collect();
let replay = stream::iter(
history
.into_iter()
.map(|event| Ok::<_, Infallible>(sse_event(&event))),
);
let live = live_api_event_stream(rx, filter, replayed).map(|event| Ok(sse_event(&event)));
Sse::new(replay.chain(live))
.keep_alive(KeepAlive::default())
.into_response()
}
fn history_response(state: &ApiState, filter: &EventFilter) -> Response {
match state.history(filter) {
Ok(history) => Json(list_response(history)).into_response(),
Err(_) => task_store::unavailable_response(),
}
}
fn live_api_event_stream(
rx: broadcast::Receiver<ApiEvent>,
filter: EventFilter,
replayed: BTreeSet<String>,
) -> impl Stream<Item = ApiEvent> {
stream::unfold(
(rx, filter, replayed),
|(mut rx, filter, replayed)| async move {
loop {
match rx.recv().await {
Ok(event) if filter.matches(&event) && !replayed.contains(&event.id) => {
return Some((event, (rx, filter, replayed)));
}
Ok(_) => continue,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
},
)
}
async fn read_api_event_log_topic(
log: &Arc<AnyEventLog>,
topic_name: &str,
) -> Result<Vec<(u64, LogEvent)>, String> {
let topic = Topic::new(topic_name).map_err(|error| error.to_string())?;
log.read_range(&topic, None, usize::MAX)
.await
.map_err(|error| error.to_string())
}
fn payload_string(payload: Option<&serde_json::Map<String, Value>>, field: &str) -> Option<String> {
payload
.and_then(|payload| payload.get(field))
.and_then(Value::as_str)
.map(str::to_string)
}
fn payload_value(payload: Option<&serde_json::Map<String, Value>>, field: &str) -> Option<Value> {
payload.and_then(|payload| payload.get(field).cloned())
}
fn workflow_trigger_run_values(
dispatches: &[(u64, LogEvent)],
action_graph: &[(u64, LogEvent)],
limit: usize,
) -> Vec<Value> {
let graph_by_event_id = action_graph_by_event_id(action_graph);
let mut recent: Vec<_> = dispatches
.iter()
.filter_map(|(event_log_id, event)| {
if !matches!(
event.kind.as_str(),
"dispatch_succeeded" | "dispatch_failed" | "dispatch_skipped"
) {
return None;
}
let payload = event.payload.as_object();
let event_id = event.headers.get("event_id").cloned();
let kind = event.kind.clone();
let status = event
.kind
.strip_prefix("dispatch_")
.unwrap_or(event.kind.as_str())
.to_string();
let action_graph = event_id
.as_deref()
.and_then(|id| graph_by_event_id.get(id))
.cloned()
.unwrap_or(Value::Null);
Some(json!({
"id": format!("workflow_trigger_run_{event_log_id}"),
"object": "workflow_trigger_run",
"event_log_id": event_log_id,
"kind": kind,
"status": status,
"occurred_at_ms": event.occurred_at_ms,
"trigger_id": event.headers.get("trigger_id").cloned(),
"event_id": event_id,
"binding_key": event.headers.get("binding_key").cloned(),
"attempt": event.headers.get("attempt").and_then(|attempt| attempt.parse::<u32>().ok()),
"replay_of_event_id": event.headers.get("replay_of_event_id").cloned(),
"handler_kind": payload_string(payload, "handler_kind"),
"target_uri": payload_string(payload, "target_uri"),
"error": payload_string(payload, "error"),
"result": payload_value(payload, "result"),
"skip_stage": payload_string(payload, "skip_stage"),
"detail": payload_value(payload, "detail"),
"action_graph": action_graph,
}))
})
.collect();
recent.sort_by_key(|value| {
value
.get("occurred_at_ms")
.and_then(Value::as_i64)
.unwrap_or_default()
});
if recent.len() > limit {
recent.drain(0..recent.len() - limit);
}
recent.reverse();
recent
}
fn action_graph_by_event_id(events: &[(u64, LogEvent)]) -> BTreeMap<String, Value> {
let mut by_event_id = BTreeMap::<String, (Vec<Value>, Vec<Value>)>::new();
for (_, event) in events {
let Some(event_id) = event.headers.get("event_id").cloned() else {
continue;
};
let entry = by_event_id.entry(event_id).or_default();
if let Some(nodes) = event
.payload
.pointer("/observability/action_graph_nodes")
.and_then(Value::as_array)
{
entry.0.extend(nodes.iter().cloned());
}
if let Some(edges) = event
.payload
.pointer("/observability/action_graph_edges")
.and_then(Value::as_array)
{
entry.1.extend(edges.iter().cloned());
}
}
by_event_id
.into_iter()
.map(|(event_id, (nodes, edges))| {
(
event_id,
json!({
"nodes": nodes,
"edges": edges,
}),
)
})
.collect()
}
fn sse_event(event: &ApiEvent) -> Event {
Event::default()
.id(event.id.clone())
.event(event.event.clone())
.json_data(event)
.unwrap_or_else(|_| Event::default().event("error").data("{}"))
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
fn event(id: u64) -> ApiEvent {
ApiEvent::new(
format!("event_{id:016x}"),
"2026-10-11T00:00:00Z".into(),
"message.created".into(),
Some("session-one".into()),
Some("task-one".into()),
json!({}),
ResourcePointer {
object: "task".into(),
id: "task-one".into(),
},
)
}
#[tokio::test]
async fn replay_dedup_keeps_lower_live_ids_and_original_cursor() {
let (tx, rx) = broadcast::channel(16);
let replayed = BTreeSet::from([event(8).id]);
let filter = EventFilter {
after: Some(event(6).id),
..EventFilter::default()
};
assert!(tx.send(event(6)).is_ok());
assert!(tx.send(event(7)).is_ok());
assert!(tx.send(event(8)).is_ok());
assert!(tx.send(event(9)).is_ok());
let live = live_api_event_stream(rx, filter, replayed);
futures::pin_mut!(live);
assert_eq!(live.next().await.unwrap().id, event(7).id);
assert_eq!(live.next().await.unwrap().id, event(9).id);
assert!(live.next().now_or_never().is_none());
}
}