use aion_core::{
ActivityEvent, ActivityEventKind, ActivityId, InterventionCapabilities, InterventionCommand,
InterventionKind, InterventionOutcome, MessageRole, WorkflowId,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use super::registry::{ConnectedWorkerRegistry, WorkerHandle, WorkerId};
use crate::error::ServerError;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct AttemptKey {
pub workflow_id: WorkflowId,
pub activity_id: ActivityId,
pub attempt: u32,
}
impl AttemptKey {
#[must_use]
pub const fn new(workflow_id: WorkflowId, activity_id: ActivityId, attempt: u32) -> Self {
Self {
workflow_id,
activity_id,
attempt,
}
}
#[must_use]
pub fn of_command(command: &InterventionCommand) -> Self {
Self::new(
command.workflow_id.clone(),
command.activity_id.clone(),
command.attempt,
)
}
}
#[derive(Clone, Debug, Default)]
pub struct AttemptOwnerIndex {
inner: Arc<Mutex<HashMap<AttemptKey, WorkerId>>>,
}
impl AttemptOwnerIndex {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn bind(&self, key: AttemptKey, worker: WorkerId) {
if let Ok(mut index) = self.inner.lock() {
index.insert(key, worker);
}
}
pub fn release(&self, key: &AttemptKey) {
if let Ok(mut index) = self.inner.lock() {
index.remove(key);
}
}
#[must_use]
pub fn owner(&self, key: &AttemptKey) -> Option<WorkerId> {
self.inner.lock().ok()?.get(key).copied()
}
#[must_use]
pub fn attempts_for_workflow(&self, workflow_id: &WorkflowId) -> Vec<(AttemptKey, WorkerId)> {
let Ok(index) = self.inner.lock() else {
return Vec::new();
};
index
.iter()
.filter(|(key, _worker)| &key.workflow_id == workflow_id)
.map(|(key, worker)| (key.clone(), *worker))
.collect()
}
}
#[async_trait]
pub trait InterventionTransport: Send + Sync {
async fn push(
&self,
worker: &WorkerHandle,
command: InterventionCommand,
) -> Result<InterventionOutcome, ServerError>;
}
pub struct InterventionRouter {
registry: ConnectedWorkerRegistry,
owners: AttemptOwnerIndex,
transport: Arc<dyn InterventionTransport>,
transcript: Option<crate::activity_publisher::ActivityEventPublisher>,
}
impl std::fmt::Debug for InterventionRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InterventionRouter").finish_non_exhaustive()
}
}
impl InterventionRouter {
#[must_use]
pub fn new(
registry: ConnectedWorkerRegistry,
owners: AttemptOwnerIndex,
transport: Arc<dyn InterventionTransport>,
) -> Self {
Self {
registry,
owners,
transport,
transcript: None,
}
}
#[must_use]
pub fn with_transcript_publisher(
mut self,
publisher: crate::activity_publisher::ActivityEventPublisher,
) -> Self {
self.transcript = Some(publisher);
self
}
#[must_use]
pub fn owners(&self) -> &AttemptOwnerIndex {
&self.owners
}
pub async fn route(
&self,
command: InterventionCommand,
) -> Result<InterventionOutcome, ServerError> {
let key = AttemptKey::of_command(&command);
let primitive = command.kind.primitive();
let Some(worker_id) = self.owners.owner(&key) else {
return Ok(stale(&key));
};
let Some(worker) = self.registry.worker_by_id(worker_id)? else {
return Ok(stale(&key));
};
if !worker.intervention_capabilities().supports(&command.kind) {
return Ok(InterventionOutcome::capability_not_supported(primitive));
}
let injected = match &command.kind {
InterventionKind::InjectMessage { text, .. } => Some((text.clone(), command.issued_at)),
_ => None,
};
match self.transport.push(&worker, command).await {
Ok(outcome) => {
if let (true, Some((text, issued_at))) = (outcome.is_applied(), injected) {
self.retain_injected_message(&key, text, issued_at).await;
}
Ok(outcome)
}
Err(error) if error.is_worker_connection_lost() => {
Ok(InterventionOutcome::stale_target(format!(
"owning worker connection lost before the command was applied: {error}"
)))
}
Err(error) => Err(error),
}
}
async fn retain_injected_message(
&self,
key: &AttemptKey,
text: String,
issued_at: chrono::DateTime<chrono::Utc>,
) {
let Some(publisher) = &self.transcript else {
return;
};
let event = ActivityEvent {
workflow_id: key.workflow_id.clone(),
activity_id: key.activity_id.clone(),
attempt: key.attempt,
agent_id: uuid::Uuid::nil(),
agent_role: "operator".to_owned(),
emitted_at: issued_at,
worker_seq: 0,
store_seq: None,
ephemeral: false,
kind: ActivityEventKind::Message {
role: MessageRole::User,
text,
},
};
if let Err(error) = publisher.publish(&event).await {
tracing::warn!(
%error,
workflow_id = %key.workflow_id,
activity_id = %key.activity_id,
attempt = key.attempt,
"applied InjectMessage could not be retained in the transcript"
);
}
}
pub fn capabilities_for(
&self,
key: &AttemptKey,
) -> Result<Option<InterventionCapabilities>, ServerError> {
let Some(worker_id) = self.owners.owner(key) else {
return Ok(None);
};
Ok(self
.registry
.worker_by_id(worker_id)?
.map(|worker| worker.intervention_capabilities().clone()))
}
pub fn intervenable_attempts(
&self,
workflow_id: &WorkflowId,
) -> Result<Vec<(AttemptKey, InterventionCapabilities)>, ServerError> {
let mut attempts = Vec::new();
for (key, worker_id) in self.owners.attempts_for_workflow(workflow_id) {
if let Some(worker) = self.registry.worker_by_id(worker_id)? {
attempts.push((key, worker.intervention_capabilities().clone()));
}
}
Ok(attempts)
}
}
fn stale(key: &AttemptKey) -> InterventionOutcome {
InterventionOutcome::stale_target(format!(
"no live owner for attempt {} of activity {} in workflow {}",
key.attempt, key.activity_id, key.workflow_id
))
}
#[cfg(test)]
#[path = "intervention_tests.rs"]
mod tests;