use std::net::SocketAddr;
use std::time::Duration;
use aion_core::{ActivityEvent, ActivityEventKind, ActivityId, MessageRole, WorkflowId};
use aion_server::api::http::workflow_router;
use aion_server::config::{
AuthConfig, AuthoringConfig, DeployConfig, ListenConfig, MetricsConfig, NamespaceConfig,
NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, RuntimeConfig, WebSocketConfig,
WorkerConfig,
};
use aion_server::{
NamespaceResolver, ServerState, StaticScheduleNamespaces, StaticWorkflowNamespaces,
};
use chrono::Utc;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use uuid::Uuid;
type TestError = Box<dyn std::error::Error>;
type ClientSocket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
const TENANT_A: &str = "tenant-a";
const TENANT_B: &str = "tenant-b";
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(2);
fn workflow_id() -> WorkflowId {
WorkflowId::new(Uuid::from_u128(0x5B))
}
const ACTIVITY_SEQ: u64 = 3;
const ATTEMPT: u32 = 0;
fn message_event(worker_seq: u64, text: &str) -> ActivityEvent {
ActivityEvent {
workflow_id: workflow_id(),
activity_id: ActivityId::from_sequence_position(ACTIVITY_SEQ),
attempt: ATTEMPT,
agent_id: Uuid::from_u128(9),
agent_role: "orchestrator".to_owned(),
emitted_at: Utc::now(),
worker_seq,
store_seq: None,
ephemeral: false,
kind: ActivityEventKind::Message {
role: MessageRole::Assistant,
text: text.to_owned(),
},
}
}
fn delta_event(worker_seq: u64, fragment: &str) -> ActivityEvent {
ActivityEvent {
workflow_id: workflow_id(),
activity_id: ActivityId::from_sequence_position(ACTIVITY_SEQ),
attempt: ATTEMPT,
agent_id: Uuid::from_u128(9),
agent_role: "orchestrator".to_owned(),
emitted_at: Utc::now(),
worker_seq,
store_seq: None,
ephemeral: true,
kind: ActivityEventKind::Delta {
message_id: "m1".to_owned(),
text_fragment: fragment.to_owned(),
},
}
}
struct TranscriptServer {
address: SocketAddr,
state: ServerState,
server: tokio::task::JoinHandle<()>,
}
impl TranscriptServer {
async fn start() -> Result<Self, TestError> {
let ownership = StaticWorkflowNamespaces::default();
ownership.record(workflow_id(), TENANT_A)?;
let resolver = NamespaceResolver::authorization_only(
NamespaceMode::SharedEngine,
ownership,
StaticScheduleNamespaces::default(),
);
let state = ServerState::from_parts(resolver, runtime_config());
let router = workflow_router(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let server = tokio::spawn(async move {
if let Err(error) = axum::serve(listener, router.into_make_service()).await {
tracing::warn!(%error, "transcript test server exited with error");
}
});
Ok(Self {
address,
state,
server,
})
}
async fn emit(&self, event: &ActivityEvent) -> Result<Option<u64>, TestError> {
Ok(self.state.transcript_publisher().publish(event).await?)
}
fn stop(self) {
self.server.abort();
}
}
fn runtime_config() -> RuntimeConfig {
RuntimeConfig {
listen: ListenConfig {
grpc: SocketAddr::from(([127, 0, 0, 1], 0)),
http: SocketAddr::from(([127, 0, 0, 1], 0)),
},
tls: None,
auth: AuthConfig {
enabled: false,
jwks_url: None,
jwks_refresh_seconds: 300,
},
ops_console: OpsConsoleConfig {
source: OpsConsoleAssetSource::Embedded,
},
namespace: NamespaceConfig {
mode: NamespaceMode::SharedEngine,
},
worker: WorkerConfig {
heartbeat_window: Duration::from_secs(30),
..Default::default()
},
websocket: WebSocketConfig {
outbound_buffer_bound: 32,
event_broadcast_capacity: Some(64),
cluster_broadcast_capacity: Some(64),
},
workflow_packages: Vec::new(),
deploy: DeployConfig::default(),
authoring: AuthoringConfig::default(),
dev: aion_server::config::DevConfig::default(),
outbox: aion_server::config::OutboxConfig::default(),
observability: aion_server::config::ObservabilityConfig::default(),
scheduler_threads: 1,
query_timeout: Some(Duration::from_secs(10)),
default_namespace: TENANT_A.to_owned(),
auto_create: aion_server::config::AutoCreate::Open,
max_in_flight_activities: aion_server::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
drain_timeout: Duration::from_secs(30),
metrics: MetricsConfig { enabled: false },
owned_shards: Vec::new(),
cors_allowed_origins: Vec::new(),
}
}
async fn connect(address: SocketAddr, namespaces: &str) -> Result<ClientSocket, TestError> {
let mut request = format!("ws://{address}/events/stream").into_client_request()?;
request
.headers_mut()
.insert("x-aion-subject", "alice".parse()?);
request
.headers_mut()
.insert("x-aion-namespaces", namespaces.parse()?);
let (socket, _response) = connect_async(request).await?;
Ok(socket)
}
async fn subscribe_transcript(
socket: &mut ClientSocket,
namespace: &str,
after_seq: Option<u64>,
) -> Result<(), TestError> {
let mut transcript = json!({
"namespace": namespace,
"workflow_id": workflow_id().to_string(),
"activity_id": ACTIVITY_SEQ,
"attempt": ATTEMPT,
});
if let Some(cursor) = after_seq {
transcript["after_seq"] = json!(cursor);
}
socket
.send(Message::Text(
json!({ "transcript": transcript }).to_string().into(),
))
.await?;
Ok(())
}
async fn next_activity_event(socket: &mut ClientSocket) -> Result<ActivityEvent, TestError> {
let frame = tokio::time::timeout(RECEIVE_TIMEOUT, socket.next())
.await
.map_err(|_| "timed out waiting for a transcript frame")?
.ok_or("socket closed before a transcript frame")??;
let Message::Text(text) = frame else {
return Err(format!("expected a text transcript frame, got {frame:?}").into());
};
let body: serde_json::Value = serde_json::from_str(&text)?;
if body.get("error").is_some() {
return Err(format!("expected a transcript event, got an error frame: {body}").into());
}
assert_eq!(
body["kind"], "activity_event",
"transcript frames must be tagged activity_event: {body}"
);
Ok(serde_json::from_value(body["event"].clone())?)
}
#[tokio::test]
async fn live_subscriber_receives_sequenced_events_and_ephemeral_deltas() -> Result<(), TestError> {
let server = TranscriptServer::start().await?;
let mut socket = connect(server.address, TENANT_A).await?;
subscribe_transcript(&mut socket, TENANT_A, None).await?;
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(server.emit(&delta_event(1, "wor")).await?, None);
assert_eq!(server.emit(&message_event(2, "word")).await?, Some(0));
let first = next_activity_event(&mut socket).await?;
assert!(first.ephemeral, "the ephemeral delta must arrive live");
assert_eq!(
first.store_seq, None,
"an ephemeral event carries no store_seq"
);
let second = next_activity_event(&mut socket).await?;
assert!(!second.ephemeral);
assert_eq!(
second.store_seq,
Some(0),
"the persisted message carries the commit-allocated store_seq"
);
assert!(matches!(
second.kind,
ActivityEventKind::Message { text, .. } if text == "word"
));
server.stop();
Ok(())
}
#[tokio::test]
async fn late_subscriber_resumes_by_store_seq_with_no_gap_or_duplicate() -> Result<(), TestError> {
let server = TranscriptServer::start().await?;
assert_eq!(server.emit(&delta_event(1, "frag")).await?, None);
assert_eq!(server.emit(&message_event(2, "m-0")).await?, Some(0));
assert_eq!(server.emit(&message_event(3, "m-1")).await?, Some(1));
assert_eq!(server.emit(&message_event(4, "m-2")).await?, Some(2));
let mut socket = connect(server.address, TENANT_A).await?;
subscribe_transcript(&mut socket, TENANT_A, Some(1)).await?;
let resumed = next_activity_event(&mut socket).await?;
assert_eq!(
resumed.store_seq,
Some(2),
"resume from store_seq 1 replays exactly the missed durable record (store_seq 2)"
);
assert!(matches!(
resumed.kind,
ActivityEventKind::Message { text, .. } if text == "m-2"
));
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(server.emit(&message_event(5, "m-3")).await?, Some(3));
assert_eq!(server.emit(&message_event(6, "m-4")).await?, Some(4));
let live_3 = next_activity_event(&mut socket).await?;
assert_eq!(live_3.store_seq, Some(3), "first live event after the seam");
let live_4 = next_activity_event(&mut socket).await?;
assert_eq!(
live_4.store_seq,
Some(4),
"contiguous, no gap, no duplicate"
);
server.stop();
Ok(())
}
#[tokio::test]
async fn fresh_subscriber_replays_full_durable_transcript_without_ephemerals()
-> Result<(), TestError> {
let server = TranscriptServer::start().await?;
server.emit(&message_event(1, "a")).await?;
server.emit(&delta_event(2, "eph")).await?;
server.emit(&message_event(3, "b")).await?;
let mut socket = connect(server.address, TENANT_A).await?;
subscribe_transcript(&mut socket, TENANT_A, None).await?;
let first = next_activity_event(&mut socket).await?;
assert_eq!(first.store_seq, Some(0));
assert!(matches!(
first.kind,
ActivityEventKind::Message { text, .. } if text == "a"
));
let second = next_activity_event(&mut socket).await?;
assert_eq!(
second.store_seq,
Some(1),
"the ephemeral delta is not persisted, so the durable replay is contiguous 0,1"
);
assert!(matches!(
second.kind,
ActivityEventKind::Message { text, .. } if text == "b"
));
server.stop();
Ok(())
}
#[tokio::test]
async fn transcript_denies_caller_without_workflow_namespace_grant() -> Result<(), TestError> {
let server = TranscriptServer::start().await?;
let mut socket = connect(server.address, TENANT_B).await?;
subscribe_transcript(&mut socket, TENANT_B, None).await?;
let frame = tokio::time::timeout(RECEIVE_TIMEOUT, socket.next())
.await
.map_err(|_| "timed out waiting for the denial frame")?
.ok_or("socket closed without a terminal error frame")??;
let Message::Text(text) = frame else {
return Err(format!("expected a terminal error frame, got {frame:?}").into());
};
let body: serde_json::Value = serde_json::from_str(&text)?;
assert!(
body["error"].is_object(),
"an unauthorized transcript subscription must receive a terminal error frame: {body}"
);
server.stop();
Ok(())
}