use std::collections::HashSet;
use aion_core::{
ActivityId, Event, RunId, WorkflowId, WorkflowStatus, display_name, run_segment,
status_from_events,
};
use aion_proto::{WireError, WireErrorCode};
use axum::{Json, extract::State};
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use super::auth::HttpCaller;
use super::error::HttpWireError;
use super::history::scoped_engine;
use crate::{CallerIdentity, ServerError, ServerState};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ChildrenRequest {
namespace: String,
workflow_id: WorkflowId,
#[serde(default)]
run_id: Option<RunId>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ChildrenResponse {
children: Vec<ChildEntry>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ChildEntry {
workflow_id: WorkflowId,
run_id: Option<RunId>,
workflow_type: String,
display_name: Option<String>,
status: Option<WorkflowStatus>,
current_activity_id: Option<ActivityId>,
current_attempt: Option<u32>,
}
pub(crate) async fn list_children(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Json(request): Json<ChildrenRequest>,
) -> Result<Json<ChildrenResponse>, HttpWireError> {
let workflow_id_wire = request.workflow_id.to_string();
let engine = scoped_engine(
&state,
&caller,
&request.namespace,
&workflow_id_wire,
&request.workflow_id,
)
.await?;
let parent_history = engine
.store()
.read_history(&request.workflow_id)
.await
.map_err(store_error)?;
let selected_run = request.run_id.or_else(|| {
parent_history.iter().rev().find_map(|event| match event {
Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
})
});
if let Some(run_id) = selected_run.as_ref()
&& !parent_history.iter().any(
|event| matches!(event, Event::WorkflowStarted { run_id: recorded, .. } if recorded == run_id),
)
{
return Err(HttpWireError(WireError::not_found(format!(
"workflow {} run {} was not found",
request.workflow_id, run_id
))));
}
let parent_run = selected_run
.as_ref()
.map_or(&[][..], |run| run_segment(&parent_history, run));
let mut seen = HashSet::new();
let mut candidates = Vec::new();
for event in parent_run {
let Event::ChildWorkflowStarted {
child_workflow_id,
workflow_type,
..
} = event
else {
continue;
};
if seen.insert(child_workflow_id.clone()) {
candidates.push((child_workflow_id.clone(), workflow_type.clone()));
}
}
let children = project_candidates(
&state,
&caller,
&request.namespace,
&request.workflow_id,
candidates,
)
.await?;
Ok(Json(ChildrenResponse { children }))
}
async fn project_candidates(
state: &ServerState,
caller: &CallerIdentity,
namespace: &str,
parent_workflow_id: &WorkflowId,
candidates: Vec<(WorkflowId, String)>,
) -> Result<Vec<ChildEntry>, HttpWireError> {
let projections = join_all(
candidates
.into_iter()
.map(|(child_workflow_id, workflow_type)| {
let state = state.clone();
let caller = caller.clone();
let namespace = namespace.to_owned();
let parent_workflow_id = parent_workflow_id.clone();
async move {
let child_wire = child_workflow_id.to_string();
let child_engine = match scoped_engine(
&state,
&caller,
&namespace,
&child_wire,
&child_workflow_id,
)
.await
{
Ok(child_engine) => child_engine,
Err(error) if error.0.code == WireErrorCode::NotFound => {
tracing::warn!(
parent_workflow_id = %parent_workflow_id,
child_workflow_id = %child_workflow_id,
"omitting child whose namespace attribution is not yet resolvable"
);
return Ok(None);
}
Err(error) if is_omitted_child_error(&error) => return Ok(None),
Err(error) => return Err(error),
};
let child_history = child_engine
.store()
.read_history(&child_workflow_id)
.await
.map_err(store_error)?;
Ok::<_, HttpWireError>(Some(project_child(
child_workflow_id,
workflow_type,
&child_history,
)))
}
}),
)
.await;
projections
.into_iter()
.try_fold(Vec::new(), |mut children, projection| {
if let Some(child) = projection? {
children.push(child);
}
Ok(children)
})
}
fn is_omitted_child_error(error: &HttpWireError) -> bool {
error.0.code == WireErrorCode::NamespaceDenied
}
fn project_child(workflow_id: WorkflowId, workflow_type: String, history: &[Event]) -> ChildEntry {
let run_id = history.iter().rev().find_map(|event| match event {
Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
});
let run = run_id
.as_ref()
.map_or(&[][..], |run_id| run_segment(history, run_id));
let (current_activity_id, current_attempt) = current_attempt(run)
.map_or((None, None), |(activity_id, attempt)| {
(Some(activity_id), Some(attempt))
});
let status = run_id.as_ref().map(|_| status_from_events(run));
ChildEntry {
workflow_id,
run_id,
workflow_type,
display_name: display_name(history),
status,
current_activity_id,
current_attempt,
}
}
fn current_attempt(events: &[Event]) -> Option<(ActivityId, u32)> {
let mut current = None;
for event in events {
match event {
Event::ActivityStarted {
activity_id,
attempt,
..
} => current = Some((activity_id.clone(), *attempt)),
Event::ActivityCompleted {
activity_id,
attempt,
..
}
| Event::ActivityFailed {
activity_id,
attempt,
..
}
| Event::ActivityCancelled {
activity_id,
attempt,
..
} if current.as_ref().is_some_and(|(id, active_attempt)| {
id == activity_id && active_attempt == attempt
}) =>
{
current = None;
}
_ => {}
}
}
current
}
fn store_error(error: aion_store::StoreError) -> HttpWireError {
HttpWireError(ServerError::from(error).to_wire_error())
}
#[cfg(test)]
mod tests {
use aion_core::{ActivityError, ActivityErrorKind, EventEnvelope, PackageVersion, Payload};
use chrono::Utc;
use serde_json::json;
use super::*;
#[test]
fn completed_attempt_is_not_projected_as_current() -> Result<(), aion_core::PayloadError> {
let workflow_id = WorkflowId::new(uuid::Uuid::from_u128(1));
let run_id = RunId::new(uuid::Uuid::from_u128(2));
let activity_id = ActivityId::from_sequence_position(3);
let envelope = |seq| EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
};
let history = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: "leg".to_owned(),
input: Payload::from_json(&json!(null))?,
run_id,
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new("a".repeat(64)),
},
Event::ActivityStarted {
envelope: envelope(2),
activity_id: activity_id.clone(),
attempt: 1,
},
Event::ActivityCompleted {
envelope: envelope(3),
activity_id,
result: Payload::from_json(&json!(null))?,
attempt: 1,
},
];
assert_eq!(current_attempt(&history), None);
Ok(())
}
#[test]
fn only_namespace_denial_is_silently_omitted_after_not_found_is_logged() {
let denied = HttpWireError(WireError::new(WireErrorCode::NamespaceDenied, "denied"));
let backend = HttpWireError(WireError::backend("store unavailable"));
assert!(is_omitted_child_error(&denied));
assert!(!is_omitted_child_error(&backend));
}
#[test]
fn failed_and_cancelled_attempts_are_not_projected_as_current() {
let activity_id = ActivityId::from_sequence_position(3);
let started = Event::ActivityStarted {
envelope: test_envelope(1),
activity_id: activity_id.clone(),
attempt: 2,
};
let failed = Event::ActivityFailed {
envelope: test_envelope(2),
activity_id: activity_id.clone(),
error: ActivityError {
kind: ActivityErrorKind::Terminal,
message: "boom".to_owned(),
details: None,
},
attempt: 2,
};
assert_eq!(current_attempt(&[started.clone(), failed]), None);
let cancelled = Event::ActivityCancelled {
envelope: test_envelope(3),
activity_id,
attempt: 2,
};
assert_eq!(current_attempt(&[started, cancelled]), None);
}
#[test]
fn mismatched_terminal_attempt_does_not_clear_the_current_attempt()
-> Result<(), aion_core::PayloadError> {
let activity_id = ActivityId::from_sequence_position(3);
let history = vec![
Event::ActivityStarted {
envelope: test_envelope(1),
activity_id: activity_id.clone(),
attempt: 2,
},
Event::ActivityCompleted {
envelope: test_envelope(2),
activity_id: activity_id.clone(),
result: Payload::from_json(&json!(null))?,
attempt: 1,
},
];
assert_eq!(current_attempt(&history), Some((activity_id, 2)));
Ok(())
}
fn test_envelope(seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: WorkflowId::new(uuid::Uuid::from_u128(1)),
}
}
}