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
32impl From<&str> for RunId {
33    fn from(value: &str) -> Self {
34        Self(value.to_owned())
35    }
36}
37
38impl std::fmt::Display for RunId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.write_str(&self.0)
41    }
42}
43
44/// Identifier of a single flight.
45#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
46#[serde(transparent)]
47pub struct FlightId(String);
48
49impl FlightId {
50    /// Mints a new identifier.
51    #[must_use]
52    pub fn generate() -> Self {
53        Self(format!("flt_{}", Ulid::new()))
54    }
55
56    /// Returns the identifier as a string slice.
57    #[must_use]
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63/// Reads an identifier that came from outside — a URL path, say.
64///
65/// Deliberately not validated. This type identifies a flight; it does not certify that one
66/// exists, and the only thing done with an identifier that names nothing is to say so. Rejecting
67/// a malformed string here would turn "no such flight" into "bad request", which tells the caller
68/// less about what actually happened.
69impl From<&str> for FlightId {
70    fn from(value: &str) -> Self {
71        Self(value.to_owned())
72    }
73}
74
75impl std::fmt::Display for FlightId {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81/// Identifier of one causal chain of flights.
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
83#[serde(transparent)]
84pub struct ItineraryId(String);
85
86impl ItineraryId {
87    /// Mints a new identifier.
88    #[must_use]
89    pub fn generate() -> Self {
90        Self(format!("itn_{}", Ulid::new()))
91    }
92
93    /// Returns the identifier as a string slice.
94    #[must_use]
95    pub fn as_str(&self) -> &str {
96        &self.0
97    }
98}
99
100/// Who sent a flight.
101///
102/// Sender identity is mandatory rather than optional: an agent behind a rendezvous receives
103/// several flights at once and must be able to tell them apart.
104#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
105#[serde(rename_all = "snake_case")]
106pub enum Origin {
107    /// Injected over the HTTP API by a human.
108    Human,
109    /// Sent by an agent via the MCP control channel.
110    Agent(AgentName),
111}
112
113impl Origin {
114    /// Returns the sending agent, or `None` for a human entry point.
115    #[must_use]
116    pub fn agent(&self) -> Option<&AgentName> {
117        match self {
118            Self::Human => None,
119            Self::Agent(name) => Some(name),
120        }
121    }
122}
123
124/// A message in transit between agents.
125///
126/// `hops_remaining` is mirrored here for the transcript. The authoritative value lives in the
127/// Tower, keyed by itinerary, because a wrapped CLI is a black box that could otherwise claim
128/// any budget it liked.
129#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
130pub struct Flight {
131    /// Unique identifier.
132    pub id: FlightId,
133    /// The chain this flight belongs to.
134    pub itinerary: ItineraryId,
135    /// Who sent it.
136    pub from: Origin,
137    /// Which agent it is addressed to.
138    pub to: AgentName,
139    /// The payload handed to the receiving agent.
140    pub body: String,
141    /// Legs remaining before the chain is cut.
142    pub hops_remaining: u32,
143    /// When the Tower accepted it.
144    pub sent_at: Timestamp,
145}
146
147impl Flight {
148    /// Creates a flight, minting a fresh identifier and timestamp.
149    #[must_use]
150    pub fn new(
151        itinerary: ItineraryId,
152        from: Origin,
153        to: AgentName,
154        body: impl Into<String>,
155        hops_remaining: u32,
156    ) -> Self {
157        Self {
158            id: FlightId::generate(),
159            itinerary,
160            from,
161            to,
162            body: body.into(),
163            hops_remaining,
164            sent_at: Timestamp::now(),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn identifiers_are_unique_and_prefixed() {
175        let first = FlightId::generate();
176        let second = FlightId::generate();
177
178        assert_ne!(first, second);
179        assert!(first.as_str().starts_with("flt_"));
180        assert!(ItineraryId::generate().as_str().starts_with("itn_"));
181    }
182
183    #[test]
184    fn human_origin_has_no_agent() {
185        assert!(Origin::Human.agent().is_none());
186        assert_eq!(
187            Origin::Agent("planner".into()).agent(),
188            Some(&AgentName::from("planner"))
189        );
190    }
191}