use aion_core::{ActivityEvent, ActivityId, WorkflowId};
use aion_store::ActivityStreamKey;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use super::auth::HttpCaller;
use super::error::HttpWireError;
use crate::ServerState;
use crate::stream::gate_transcript_workflow;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct TranscriptFetchRequest {
pub namespace: String,
pub workflow_id: WorkflowId,
pub activity_id: ActivityId,
pub attempt: u32,
#[serde(default)]
pub from_seq: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct TranscriptFetchResponse {
pub events: Vec<ActivityEvent>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct TranscriptStreamsRequest {
pub namespace: String,
pub workflow_id: WorkflowId,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct TranscriptStreamEntry {
pub activity_id: ActivityId,
pub attempt: u32,
pub head: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct TranscriptStreamsResponse {
pub streams: Vec<TranscriptStreamEntry>,
}
pub(crate) async fn fetch_transcript(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Json(request): Json<TranscriptFetchRequest>,
) -> Result<Json<TranscriptFetchResponse>, HttpWireError> {
gate_transcript_workflow(&state, &caller, &request.namespace, &request.workflow_id)
.await
.map_err(|error| HttpWireError(error.to_wire_error()))?;
let key = ActivityStreamKey::new(request.workflow_id, request.activity_id, request.attempt);
let records = state
.transcript_publisher()
.replay_from(&key, request.from_seq.unwrap_or(0))
.await
.map_err(|error| HttpWireError(crate::ServerError::from(error).to_wire_error()))?;
Ok(Json(TranscriptFetchResponse {
events: records.into_iter().map(|record| record.event).collect(),
}))
}
pub(crate) async fn list_transcript_streams(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Json(request): Json<TranscriptStreamsRequest>,
) -> Result<Json<TranscriptStreamsResponse>, HttpWireError> {
gate_transcript_workflow(&state, &caller, &request.namespace, &request.workflow_id)
.await
.map_err(|error| HttpWireError(error.to_wire_error()))?;
let summaries = state
.transcript_publisher()
.list_streams(&request.workflow_id)
.await
.map_err(|error| HttpWireError(crate::ServerError::from(error).to_wire_error()))?;
Ok(Json(TranscriptStreamsResponse {
streams: summaries
.into_iter()
.map(|summary| TranscriptStreamEntry {
activity_id: summary.key.activity_id,
attempt: summary.key.attempt,
head: summary.head,
})
.collect(),
}))
}
#[cfg(test)]
mod tests {
use aion_core::{ActivityEventKind, MessageRole};
use chrono::Utc;
use uuid::Uuid;
use super::*;
#[test]
fn fetch_request_round_trips_and_from_seq_defaults() -> Result<(), Box<dyn std::error::Error>> {
let request = TranscriptFetchRequest {
namespace: "tenant-a".to_owned(),
workflow_id: WorkflowId::new(Uuid::nil()),
activity_id: ActivityId::from_sequence_position(3),
attempt: 1,
from_seq: Some(7),
};
let json = serde_json::to_string(&request)?;
let decoded: TranscriptFetchRequest = serde_json::from_str(&json)?;
assert_eq!(decoded.namespace, "tenant-a");
assert_eq!(decoded.attempt, 1);
assert_eq!(decoded.from_seq, Some(7));
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["activity_id"], serde_json::json!(3));
let minimal: TranscriptFetchRequest = serde_json::from_value(serde_json::json!({
"namespace": "tenant-a",
"workflow_id": WorkflowId::new(Uuid::nil()),
"activity_id": 3,
"attempt": 0,
}))?;
assert_eq!(minimal.from_seq, None);
Ok(())
}
#[test]
fn fetch_response_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let response = TranscriptFetchResponse {
events: vec![ActivityEvent {
workflow_id: WorkflowId::new(Uuid::nil()),
activity_id: ActivityId::from_sequence_position(3),
attempt: 0,
agent_id: Uuid::nil(),
agent_role: "operator".to_owned(),
emitted_at: Utc::now(),
worker_seq: 0,
store_seq: Some(4),
ephemeral: false,
kind: ActivityEventKind::Message {
role: MessageRole::User,
text: "steer".to_owned(),
},
}],
};
let json = serde_json::to_string(&response)?;
let decoded: TranscriptFetchResponse = serde_json::from_str(&json)?;
assert_eq!(decoded.events.len(), 1);
assert_eq!(decoded.events[0].store_seq, Some(4));
let empty: TranscriptFetchResponse = serde_json::from_str(r#"{"events":[]}"#)?;
assert!(empty.events.is_empty());
Ok(())
}
#[test]
fn streams_request_and_response_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let request = TranscriptStreamsRequest {
namespace: "tenant-a".to_owned(),
workflow_id: WorkflowId::new(Uuid::nil()),
};
let json = serde_json::to_string(&request)?;
let decoded: TranscriptStreamsRequest = serde_json::from_str(&json)?;
assert_eq!(decoded.namespace, "tenant-a");
let response = TranscriptStreamsResponse {
streams: vec![TranscriptStreamEntry {
activity_id: ActivityId::from_sequence_position(3),
attempt: 0,
head: 5,
}],
};
let json = serde_json::to_string(&response)?;
let decoded: TranscriptStreamsResponse = serde_json::from_str(&json)?;
assert_eq!(decoded.streams.len(), 1);
assert_eq!(decoded.streams[0].head, 5);
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["streams"][0]["activity_id"], serde_json::json!(3));
let empty: TranscriptStreamsResponse = serde_json::from_str(r#"{"streams":[]}"#)?;
assert!(empty.streams.is_empty());
Ok(())
}
}