Skip to main content

aion_core/
describe_live.rs

1//! The live describe projection: what a run is doing RIGHT NOW, in one shape.
2//!
3//! [`DescribeWorkflowResponse`](crate::DescribeWorkflowResponse) answers what
4//! history records. This answers the mid-step question history alone cannot: a
5//! run projected `Running` covers "an agent is three tool calls into step four"
6//! and "step four was handed to a queue nobody serves in July", and an operator
7//! (or an agent driving aion through a tool call) needs those told apart in one
8//! read.
9//!
10//! # Every absence in this shape is TYPED
11//!
12//! The rule the whole module is built on: a reader must never be able to
13//! mistake "the server cannot answer" for "the answer is nothing". So:
14//!
15//! - No current step is [`Option::None`] on [`DescribeLiveResponse::current_step`],
16//!   which means the run is genuinely not inside an activity — not that the
17//!   fold failed.
18//! - A step that exists but was never dispatched has no [`LiveAttempt`], so it
19//!   cannot report liveness, notes, or a transcript it structurally cannot have.
20//! - The progress note distinguishes reported / none-sent / unavailable
21//!   ([`HeartbeatNote`]), because notes are held in volatile per-process state:
22//!   a restarted server holds none, and reporting that as "the worker has said
23//!   nothing" would be a lie about a worker that may be talking continuously.
24//! - A transcript stream the retention cap has closed says
25//!   `retention_truncated`, so a reader seeing the last retained event knows
26//!   the stream continued rather than that the agent went quiet.
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    ActivityEvent, ActivityId, InterventionCapabilities, Payload, UnservedActivity, WorkflowSummary,
33};
34
35/// The one-call live view of a run.
36///
37/// `summary` carries the projected [`crate::WorkflowStatus`] — the status is not
38/// repeated at the top level, because two copies of one projection in one
39/// response is a disagreement waiting to be reported as a bug.
40#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
41pub struct DescribeLiveResponse {
42    /// Workflow summary projected from authoritative history (carrying the
43    /// projected status), when the workflow exists.
44    pub summary: Option<WorkflowSummary>,
45    /// The single step a reader is told the run is on: the open step whose last
46    /// history event is the most recent. `None` when the run is not inside an
47    /// activity at all (completed, failed, or blocked on a timer or signal).
48    pub current_step: Option<CurrentStep>,
49    /// EVERY open step, in the order the ordinals were opened.
50    ///
51    /// A fan-out has several steps open at once, and [`Self::current_step`]
52    /// reports one of them. This list is how a reader sees the siblings rather
53    /// than having to know they might exist.
54    pub open_steps: Vec<OpenStep>,
55    /// Every retained transcript stream of the workflow, annotated with the
56    /// activity type it belongs to and whether it is the current attempt's.
57    pub transcript_streams: Vec<TranscriptStreamHead>,
58    /// Every activity this run's history records as dispatched and unterminated
59    /// that the live fleet cannot currently serve.
60    ///
61    /// EMPTY is the healthy answer and the only healthy one — see
62    /// [`UnservedActivity`], whose semantics this field shares exactly (it is
63    /// the same projection `POST /workflows/describe` returns).
64    pub unserved: Vec<UnservedActivity>,
65}
66
67/// One open step of a run: the ordinal, the address the engine stamped on it,
68/// and where it has got to.
69#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
70pub struct OpenStep {
71    /// Activity ordinal recorded in history.
72    pub activity_id: ActivityId,
73    /// Activity type the step runs.
74    pub activity_type: String,
75    /// Task queue stamped on the recorded `ActivityScheduled` — the address the
76    /// dispatch really went to, never a re-resolution that could disagree.
77    pub task_queue: String,
78    /// Node affinity stamped on the recorded `ActivityScheduled`, if any.
79    pub node: Option<String>,
80    /// `recorded_at` of the `ActivityScheduled` that opened the ordinal.
81    pub scheduled_at: DateTime<Utc>,
82    /// How far the step has got in the recorded lifecycle.
83    pub state: StepState,
84}
85
86/// How far an open step has got — the lifecycle position history records.
87#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
88#[serde(tag = "state")]
89pub enum StepState {
90    /// Scheduled by workflow code; this segment records no dispatch for it yet.
91    Scheduled,
92    /// The engine DISPATCHED this attempt to its task queue.
93    ///
94    /// This is not proof a worker took the work: `ActivityStarted` is recorded
95    /// at dispatch, before worker selection. Whether anyone is actually working
96    /// on it is [`LiveAttempt::liveness`], and whether anyone COULD is
97    /// [`DescribeLiveResponse::unserved`].
98    Dispatched {
99        /// One-based delivery attempt recorded on the `ActivityStarted`.
100        attempt: u32,
101        /// When the ENGINE dispatched — NOT when a worker took it.
102        dispatched_at: DateTime<Utc>,
103    },
104    /// An operator reopen superseded this ordinal's recorded terminal; it is
105    /// open again and awaiting re-dispatch on replay.
106    Reopened {
107        /// `recorded_at` of the `WorkflowReopened` that re-opened the ordinal.
108        reopened_at: DateTime<Utc>,
109    },
110}
111
112/// The current step plus, when it has been dispatched, everything live about
113/// the attempt.
114#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
115pub struct CurrentStep {
116    /// The step itself, as history records it.
117    pub step: OpenStep,
118    /// The live view of the dispatched attempt.
119    ///
120    /// `None` when the step is [`StepState::Scheduled`] or
121    /// [`StepState::Reopened`]: there is no attempt yet, so there is nothing
122    /// that could be live, could have sent a note, or could have a transcript.
123    /// That is a structural absence, not a missing measurement.
124    pub attempt: Option<LiveAttempt>,
125}
126
127/// Everything the server can say about the dispatched attempt of the current
128/// step, right now.
129#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
130pub struct LiveAttempt {
131    /// One-based delivery attempt this view describes.
132    pub attempt: u32,
133    /// When the engine dispatched this attempt.
134    pub dispatched_at: DateTime<Utc>,
135    /// Whether a connected worker owns the attempt right now.
136    pub liveness: AttemptLiveness,
137    /// The most recent worker progress note, or a typed reason there is none.
138    pub note: HeartbeatNote,
139    /// The bounded tail of the attempt's retained transcript.
140    pub transcript: TranscriptTail,
141}
142
143/// Whether a connected worker owns an attempt right now.
144///
145/// Read from the live attempt→owner index the intervention router resolves
146/// against — the SAME source `POST /workflows/attempts` enumerates, so what a
147/// reader is told here and what an intervention would find cannot disagree.
148#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
149#[serde(tag = "liveness")]
150pub enum AttemptLiveness {
151    /// A connected worker owns the attempt and advertises these primitives.
152    ///
153    /// An EMPTY capability set is first-class: an observability-only harness
154    /// owns its attempt and supports no controls.
155    Live {
156        /// The owning worker's advertised intervention capabilities.
157        capabilities: InterventionCapabilities,
158    },
159    /// No connected worker owns the attempt.
160    ///
161    /// The attempt finished, was superseded, was never leased, or its owner has
162    /// disconnected. Paired with a `Dispatched` step and an empty `unserved`,
163    /// this is the shape of work in the hand-off window between dispatch and
164    /// lease.
165    NoLiveOwner,
166}
167
168/// The most recent progress note the worker serving an attempt has reported —
169/// or a typed statement of why there is none.
170///
171/// **Notes are VOLATILE.** They live in the server process's per-attempt
172/// liveness state and are never written to the store, so a restart loses every
173/// note ever reported. That is why absence has two forms and they are never
174/// collapsed: [`Self::NoneSent`] is a measurement (this process is holding the
175/// attempt and nothing has been reported on it), while [`Self::Unavailable`] is
176/// the absence of a measurement (this process does not hold the attempt, so it
177/// cannot say what was reported).
178#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
179#[serde(tag = "note")]
180pub enum HeartbeatNote {
181    /// The worker serving this attempt reported this note.
182    Reported {
183        /// The worker's opaque progress payload, exactly as it was sent.
184        payload: Payload,
185        /// When this server process received the reporting heartbeat.
186        reported_at: DateTime<Utc>,
187    },
188    /// This server process holds the attempt's liveness entry and no note has
189    /// been reported on it. The worker really has said nothing.
190    NoneSent,
191    /// This server process does not hold the attempt's liveness entry, so it
192    /// cannot say whether a note was reported.
193    ///
194    /// The usual cause is a restart: notes began being held at
195    /// `notes_held_since`, and an attempt dispatched before that instant left
196    /// its notes in a process that no longer exists —
197    /// `restarted_since_attempt_began` says so outright. The other causes (the
198    /// attempt was swept off the tracker with its owner, or it has not been
199    /// leased) are equally unmeasurable here, and every one of them is reported
200    /// as this variant rather than as silence.
201    Unavailable {
202        /// When this server process began holding progress notes.
203        notes_held_since: DateTime<Utc>,
204        /// True when the attempt was dispatched BEFORE this process began
205        /// holding notes — a restart demonstrably explains the absence.
206        restarted_since_attempt_began: bool,
207    },
208}
209
210/// The bounded tail of one attempt's retained transcript.
211#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
212#[serde(tag = "transcript")]
213pub enum TranscriptTail {
214    /// The caller did not ask for a tail (no `tail` in the request), so none
215    /// was read. Nothing is claimed about what the stream holds.
216    NotRequested,
217    /// The last records of the attempt's retained transcript.
218    Window {
219        /// The retained events, in `store_seq` order, each carrying its
220        /// `store_seq`. Empty when the stream has nothing retained.
221        events: Vec<ActivityEvent>,
222        /// Next durable `store_seq` for the stream at read time.
223        head_seq: u64,
224        /// Retained records BEFORE this window, omitted by the requested bound.
225        /// Read the full stream with `POST /workflows/transcript`.
226        omitted_before: u64,
227        /// The retention cap has closed this stream: the cap marker is the last
228        /// durable record and every later event is live-only, never persisted.
229        ///
230        /// A reader seeing the last retained event on a truncated stream is
231        /// seeing the end of the RETENTION, not the end of the agent's output.
232        retention_truncated: bool,
233    },
234}
235
236/// One retained transcript stream of the workflow, annotated so a reader can
237/// tell whose it is and whether it is the one still being written.
238#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
239pub struct TranscriptStreamHead {
240    /// The activity within the workflow.
241    pub activity_id: ActivityId,
242    /// The attempt — the third stream axis. Two attempts of one activity are
243    /// DISTINCT streams.
244    pub attempt: u32,
245    /// The activity type this stream belongs to, resolved from the run's own
246    /// history. `None` when the run's history records no scheduling for the
247    /// ordinal (a stream retained from a segment this history no longer holds).
248    pub activity_type: Option<String>,
249    /// Next `store_seq` to be written == count of retained records.
250    pub head_seq: u64,
251    /// Whether this stream is the current step's dispatched attempt.
252    pub current: bool,
253    /// The retention cap has closed this stream (see
254    /// [`TranscriptTail::Window::retention_truncated`]).
255    pub retention_truncated: bool,
256}