Skip to main content

layover_core/
flight.rs

1//! The Flight envelope, and the identifiers that track work through the Tower.
2
3use jiff::Timestamp;
4
5use serde::{Deserialize, Serialize};
6use ulid::Ulid;
7
8use crate::agent::AgentName;
9
10/// Identifier of one supervised CLI execution.
11///
12/// Lives here with the other identifiers rather than with the cost ledger that first needed it: a
13/// run is a core domain object, and several parts of the Tower will key off it.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
15#[serde(transparent)]
16pub struct RunId(String);
17
18impl RunId {
19    /// Mints a new identifier.
20    #[must_use]
21    pub fn generate() -> Self {
22        Self(format!("run_{}", Ulid::new()))
23    }
24
25    /// Returns the identifier as a string slice.
26    #[must_use]
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30
31    /// When this identifier was minted, which is when its run began.
32    ///
33    /// A ULID carries a millisecond timestamp in its leading bits, so a run's own name says how
34    /// old it is. That matters because a run's Hangar outlives the process: retention needs a
35    /// directory's age, and asking the *name* is exact where asking the filesystem is a guess — a
36    /// copy, a restore or a backup tool rewrites `mtime`, which would make old work look new or
37    /// new work look expired.
38    ///
39    /// `None` when the identifier was not minted by [`Self::generate`] — anything hand-made, or a
40    /// directory somebody else left in a Hangar. Callers should read that as "do not touch"
41    /// rather than "too old".
42    #[must_use]
43    pub fn minted_at(&self) -> Option<Timestamp> {
44        let ulid: Ulid = self.0.strip_prefix("run_")?.parse().ok()?;
45
46        Timestamp::from_millisecond(i64::try_from(ulid.timestamp_ms()).ok()?).ok()
47    }
48}
49
50impl From<&str> for RunId {
51    fn from(value: &str) -> Self {
52        Self(value.to_owned())
53    }
54}
55
56impl std::fmt::Display for RunId {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62/// Identifier of a single flight.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
64#[serde(transparent)]
65pub struct FlightId(String);
66
67impl FlightId {
68    /// Mints a new identifier.
69    #[must_use]
70    pub fn generate() -> Self {
71        Self(format!("flt_{}", Ulid::new()))
72    }
73
74    /// Returns the identifier as a string slice.
75    #[must_use]
76    pub fn as_str(&self) -> &str {
77        &self.0
78    }
79}
80
81/// Reads an identifier that came from outside — a URL path, say.
82///
83/// Deliberately not validated. This type identifies a flight; it does not certify that one
84/// exists, and the only thing done with an identifier that names nothing is to say so. Rejecting
85/// a malformed string here would turn "no such flight" into "bad request", which tells the caller
86/// less about what actually happened.
87impl From<&str> for FlightId {
88    fn from(value: &str) -> Self {
89        Self(value.to_owned())
90    }
91}
92
93impl std::fmt::Display for FlightId {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(&self.0)
96    }
97}
98
99/// Identifier of one causal chain of flights.
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
101#[serde(transparent)]
102pub struct ItineraryId(String);
103
104impl ItineraryId {
105    /// Mints a new identifier.
106    #[must_use]
107    pub fn generate() -> Self {
108        Self(format!("itn_{}", Ulid::new()))
109    }
110
111    /// Returns the identifier as a string slice.
112    #[must_use]
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116}
117
118/// Who sent a flight.
119///
120/// Sender identity is mandatory rather than optional: an agent behind a rendezvous receives
121/// several flights at once and must be able to tell them apart.
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum Origin {
125    /// Injected over the HTTP API by a human.
126    Human,
127    /// Sent by an agent via the MCP control channel.
128    Agent(AgentName),
129}
130
131impl Origin {
132    /// Returns the sending agent, or `None` for a human entry point.
133    #[must_use]
134    pub fn agent(&self) -> Option<&AgentName> {
135        match self {
136            Self::Human => None,
137            Self::Agent(name) => Some(name),
138        }
139    }
140}
141
142/// A message in transit between agents.
143///
144/// `hops_remaining` is mirrored here for the transcript. The authoritative value lives in the
145/// Tower, keyed by itinerary, because a wrapped CLI is a black box that could otherwise claim
146/// any budget it liked.
147#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
148pub struct Flight {
149    /// Unique identifier.
150    pub id: FlightId,
151    /// The chain this flight belongs to.
152    pub itinerary: ItineraryId,
153    /// Who sent it.
154    pub from: Origin,
155    /// Which agent it is addressed to.
156    pub to: AgentName,
157    /// The payload handed to the receiving agent.
158    pub body: String,
159    /// Legs remaining before the chain is cut.
160    pub hops_remaining: u32,
161    /// When the Tower accepted it.
162    pub sent_at: Timestamp,
163}
164
165impl Flight {
166    /// Creates a flight, minting a fresh identifier and timestamp.
167    #[must_use]
168    pub fn new(
169        itinerary: ItineraryId,
170        from: Origin,
171        to: AgentName,
172        body: impl Into<String>,
173        hops_remaining: u32,
174    ) -> Self {
175        Self {
176            id: FlightId::generate(),
177            itinerary,
178            from,
179            to,
180            body: body.into(),
181            hops_remaining,
182            sent_at: Timestamp::now(),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn identifiers_are_unique_and_prefixed() {
193        let first = FlightId::generate();
194        let second = FlightId::generate();
195
196        assert_ne!(first, second);
197        assert!(first.as_str().starts_with("flt_"));
198        assert!(ItineraryId::generate().as_str().starts_with("itn_"));
199    }
200
201    #[test]
202    fn a_run_id_says_when_it_was_minted() {
203        // Retention for Hangars reads the age off the directory name rather than off the
204        // filesystem, so this is the thing that decides whether a transcript is kept.
205        let before = Timestamp::now();
206        let run = RunId::generate();
207        let after = Timestamp::now();
208
209        let minted = run.minted_at().expect("a generated id decodes");
210
211        // ULIDs carry whole milliseconds, so the lower bound is rounded down rather than equal.
212        assert!(
213            minted.as_millisecond() >= before.as_millisecond() - 1
214                && minted <= after + jiff::Span::new().milliseconds(1),
215            "minted {minted} is outside {before}..{after}"
216        );
217    }
218
219    #[test]
220    fn a_real_run_id_decodes_to_when_that_run_happened() {
221        // Taken from the 48-hour soak, whose first `analyst` run started on 2026-09-21. A
222        // hand-written expectation rather than a round trip: if the decoding were subtly wrong --
223        // wrong epoch, wrong bit width -- a round trip would agree with itself and still delete
224        // the wrong Hangars.
225        let minted = RunId::from("run_01M31S7S94MCCD56RC8S6TFQ4Y")
226            .minted_at()
227            .expect("decodes");
228
229        let day = minted.to_string();
230        assert!(day.starts_with("2026-09-21"), "decoded to {day}");
231    }
232
233    #[test]
234    fn an_id_not_minted_here_has_no_time_rather_than_a_wrong_one() {
235        // Retention reads this as "do not touch". Returning some default would make a directory
236        // somebody else put in a Hangar look infinitely old and delete it.
237        assert!(RunId::from("my-notes").minted_at().is_none());
238        assert!(RunId::from("run_not-a-ulid").minted_at().is_none());
239        assert!(
240            RunId::from("itn_01M31S7S94MCCD56RC8S6TFQ4Y")
241                .minted_at()
242                .is_none()
243        );
244    }
245
246    #[test]
247    fn human_origin_has_no_agent() {
248        assert!(Origin::Human.agent().is_none());
249        assert_eq!(
250            Origin::Agent("planner".into()).agent(),
251            Some(&AgentName::from("planner"))
252        );
253    }
254}