use aion_core::{
ActivityId, AttemptLiveness, CurrentStep, DescribeLiveResponse, Event, HeartbeatNote,
LiveAttempt, OpenStep, RunId, StepState, TranscriptStreamHead, TranscriptTail, WorkflowId,
};
use aion_proto::ProtoDescribeWorkflowRequest;
use aion_store::{ActivityRecord, ActivityStreamKey};
use axum::{Json, extract::State};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::auth::HttpCaller;
use super::clean_dtos::{optional_proto_run_id, proto_workflow_id};
use super::error::HttpWireError;
use super::payload::describe_response_to_ops_console;
use super::transcripts::transcript_head;
use crate::worker::{ActivityReachability, AttemptProgress, attempt_progress};
use crate::{ServerError, ServerState, api::handlers};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct DescribeLiveRequest {
pub namespace: String,
pub workflow_id: String,
#[serde(default)]
pub run_id: Option<String>,
#[serde(default)]
pub tail: Option<u32>,
}
pub(crate) async fn describe_live(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Json(request): Json<DescribeLiveRequest>,
) -> Result<Json<DescribeLiveResponse>, HttpWireError> {
let tail = request.tail;
let described = ProtoDescribeWorkflowRequest {
namespace: request.namespace,
workflow_id: Some(proto_workflow_id(&request.workflow_id).map_err(HttpWireError)?),
run_id: optional_proto_run_id(request.run_id.as_deref()).map_err(HttpWireError)?,
include_history: false,
};
let outcome = handlers::describe(
state.namespace_guard(),
&caller,
described,
state.read_provenance(),
)
.await
.map_err(HttpWireError)?;
let summary = describe_response_to_ops_console(&outcome.response)?.summary;
let unserved = ActivityReachability {
registry: state.worker_registry(),
declarations: state.queue_declarations(),
state: state.queue_service_state(),
}
.unserved(&outcome.namespace, &outcome.workflow_id, &outcome.history)
.map_err(|error| HttpWireError(error.to_wire_error()))?;
let open_steps = aion_core::open_steps(&outcome.history);
let current = aion_core::current_step(&outcome.history);
let current_attempt = current.as_ref().and_then(dispatched_attempt);
let current_run = current_generation_run(&outcome.history);
let current_step = match current {
Some(step) => Some(CurrentStep {
attempt: match current_run.as_ref() {
Some(run_id) => {
live_attempt(&state, &outcome.workflow_id, run_id, &step, tail).await?
}
None => None,
},
step,
}),
None => None,
};
let transcript_streams = match current_run.as_ref() {
Some(run_id) => {
transcript_streams(
&state,
&outcome.workflow_id,
run_id,
&outcome.history,
current_attempt,
)
.await?
}
None => Vec::new(),
};
Ok(Json(DescribeLiveResponse {
summary,
current_step,
open_steps,
transcript_streams,
unserved,
}))
}
fn dispatched_attempt(step: &OpenStep) -> Option<(ActivityId, u32)> {
match step.state {
StepState::Dispatched { attempt, .. } => Some((step.activity_id.clone(), attempt)),
StepState::Scheduled | StepState::Reopened { .. } => None,
}
}
fn current_generation_run(history: &[Event]) -> Option<RunId> {
history.iter().rev().find_map(|event| match event {
Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
})
}
async fn live_attempt(
state: &ServerState,
workflow_id: &WorkflowId,
run_id: &RunId,
step: &OpenStep,
tail: Option<u32>,
) -> Result<Option<LiveAttempt>, HttpWireError> {
let StepState::Dispatched {
attempt,
dispatched_at,
} = step.state
else {
return Ok(None);
};
let key = ActivityStreamKey::new(
workflow_id.clone(),
run_id.clone(),
step.activity_id.clone(),
attempt,
);
Ok(Some(LiveAttempt {
attempt,
dispatched_at,
liveness: liveness(state, workflow_id, &step.activity_id, attempt)?,
note: note(
state,
workflow_id,
&step.activity_id,
attempt,
dispatched_at,
)?,
transcript: transcript(state, &key, tail).await?,
}))
}
fn liveness(
state: &ServerState,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
attempt: u32,
) -> Result<AttemptLiveness, HttpWireError> {
let attempts = state
.intervention_router()
.intervenable_attempts(workflow_id)
.map_err(|error| HttpWireError(error.to_wire_error()))?;
let owned = attempts
.into_iter()
.find(|(key, _capabilities)| &key.activity_id == activity_id && key.attempt == attempt);
Ok(match owned {
Some((_key, capabilities)) => AttemptLiveness::Live { capabilities },
None => AttemptLiveness::NoLiveOwner,
})
}
fn note(
state: &ServerState,
workflow_id: &WorkflowId,
activity_id: &ActivityId,
attempt: u32,
dispatched_at: DateTime<Utc>,
) -> Result<HeartbeatNote, HttpWireError> {
let tracker = state.heartbeat_tracker();
let progress = attempt_progress(tracker, workflow_id, activity_id, attempt)
.map_err(|error| HttpWireError(error.to_wire_error()))?;
Ok(match progress {
AttemptProgress::Reported {
payload,
reported_at,
} => HeartbeatNote::Reported {
payload,
reported_at,
},
AttemptProgress::NoneSent => HeartbeatNote::NoneSent,
AttemptProgress::Untracked => {
let notes_held_since = tracker.notes_held_since();
HeartbeatNote::Unavailable {
notes_held_since,
restarted_since_attempt_began: dispatched_at < notes_held_since,
}
}
})
}
async fn transcript(
state: &ServerState,
key: &ActivityStreamKey,
tail: Option<u32>,
) -> Result<TranscriptTail, HttpWireError> {
let Some(tail) = tail else {
return Ok(TranscriptTail::NotRequested);
};
let mut records = state
.transcript_publisher()
.replay_from(key, 0)
.await
.map_err(|error| HttpWireError(ServerError::from(error).to_wire_error()))?;
crate::transcript_resolve::note_unresolved(
"http:workflows/describe-live",
&crate::transcript_resolve::resolve_records(&mut records),
);
let head_seq = transcript_head(&records)?;
let tail = usize::try_from(tail).unwrap_or(usize::MAX);
let omitted = records.len().saturating_sub(tail);
drop(records.drain(..omitted));
Ok(TranscriptTail::Window {
events: records
.into_iter()
.map(|record: ActivityRecord| record.event)
.collect(),
head_seq,
omitted_before: u64::try_from(omitted).unwrap_or(u64::MAX),
retention_truncated: retention_truncated(head_seq, retention_cap(state)),
})
}
async fn transcript_streams(
state: &ServerState,
workflow_id: &WorkflowId,
run_id: &RunId,
history: &[Event],
current: Option<(ActivityId, u32)>,
) -> Result<Vec<TranscriptStreamHead>, HttpWireError> {
let summaries = state
.transcript_publisher()
.list_streams(workflow_id, run_id)
.await
.map_err(|error| HttpWireError(ServerError::from(error).to_wire_error()))?;
let cap = retention_cap(state);
Ok(summaries
.into_iter()
.map(|summary| TranscriptStreamHead {
current: current.as_ref().is_some_and(|(activity_id, attempt)| {
activity_id == &summary.key.activity_id && *attempt == summary.key.attempt
}),
activity_type: activity_type_of(history, &summary.key.activity_id),
retention_truncated: retention_truncated(summary.head, cap),
activity_id: summary.key.activity_id,
attempt: summary.key.attempt,
head_seq: summary.head,
})
.collect())
}
fn retention_cap(state: &ServerState) -> u64 {
state.runtime_config().observability.max_stream_events
}
const fn retention_truncated(head_seq: u64, cap: u64) -> bool {
head_seq > cap
}
fn activity_type_of(history: &[Event], activity_id: &ActivityId) -> Option<String> {
history.iter().rev().find_map(|event| match event {
Event::ActivityScheduled {
activity_id: scheduled,
activity_type,
..
} if scheduled == activity_id => Some(activity_type.clone()),
_ => None,
})
}
#[cfg(test)]
#[path = "describe_live_tests.rs"]
mod tests;