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}