1use serde::{Deserialize, Serialize};
2
3use crate::error::{EnvoyError, Result};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum PartContent {
9 Text(String),
10 Data(serde_json::Value),
11 Url(String),
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Part {
16 #[serde(flatten)]
17 pub content: PartContent,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct MessageEnvelope {
23 pub message_id: String,
24 #[serde(rename = "type")]
25 pub msg_type: MessageType,
26 pub from: String,
27 pub to: String,
28 #[serde(default)]
29 pub task_id: Option<String>,
30 #[serde(default)]
31 pub context_id: Option<String>,
32 pub timestamp: String,
33 pub sequence_id: i64,
34 pub parts: Vec<Part>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(rename_all = "snake_case")]
39pub enum MessageType {
40 Direct,
41 Handoff,
42 Heartbeat,
43 System,
44}
45
46impl MessageType {
47 pub fn as_str(&self) -> &'static str {
48 match self {
49 Self::Direct => "direct",
50 Self::Handoff => "handoff",
51 Self::Heartbeat => "heartbeat",
52 Self::System => "system",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
58#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
59pub enum CompletionStatus {
60 Done,
61 DoneWithConcerns,
62 Blocked,
63 NeedsContext,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WhatWasDone {
68 pub scope: String,
69 pub change: String,
70 pub verified: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct WhatIsStubbed {
75 pub location: String,
76 pub reason: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct VerificationState {
81 pub tests_passing: i64,
82 pub tests_failing: i64,
83 pub quality_gate: QualityGateResult,
84 pub cargo_check_passed: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QualityGateResult {
89 pub passed: bool,
90 pub blocking: i64,
91 pub warnings: i64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct MagellanTracePayload {
96 pub files_changed: Vec<String>,
97 pub symbols_added: Vec<String>,
98 pub symbols_removed: Vec<String>,
99 #[serde(default)]
100 pub refs_in: serde_json::Map<String, serde_json::Value>,
101 #[serde(default)]
102 pub refs_out: serde_json::Map<String, serde_json::Value>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HandoffData {
107 pub completion_status: CompletionStatus,
108 #[serde(default)]
109 pub blocked_reason: Option<String>,
110 pub context_remaining_pct: u8,
111 pub what_was_done: Vec<WhatWasDone>,
112 pub what_is_stubbed: Vec<WhatIsStubbed>,
113 pub remaining_work: Vec<String>,
114 pub verification_state: VerificationState,
115 pub magellan_trace: MagellanTracePayload,
116 pub grounded_queries_used: Vec<String>,
117}
118
119impl HandoffData {
120 pub fn validate(&self) -> Result<()> {
121 if self.completion_status == CompletionStatus::Blocked && self.blocked_reason.is_none() {
122 return Err(EnvoyError::InvalidMessage(
123 "blocked_reason is required when status is BLOCKED".into(),
124 ));
125 }
126 if self.context_remaining_pct > 100 {
127 return Err(EnvoyError::InvalidMessage(
128 "context_remaining_pct must be 0-100".into(),
129 ));
130 }
131 Ok(())
132 }
133}
134
135impl MessageEnvelope {
136 pub const MAX_BODY_SIZE: usize = 1_048_576; pub const MAX_PARTS: usize = 20;
138
139 pub fn validate(&self) -> Result<()> {
140 if self.parts.is_empty() {
141 return Err(EnvoyError::InvalidMessage(
142 "at least one part required".into(),
143 ));
144 }
145 if self.parts.len() > Self::MAX_PARTS {
146 return Err(EnvoyError::TooManyParts(self.parts.len()));
147 }
148 for part in &self.parts {
149 if let PartContent::Text(ref text) = &part.content {
150 if text.len() > Self::MAX_BODY_SIZE {
151 return Err(EnvoyError::MessageTooLarge(text.len()));
152 }
153 }
154 }
155 Ok(())
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn serialize_message_envelope() {
165 let msg = MessageEnvelope {
166 message_id: "m-001".into(),
167 msg_type: MessageType::Direct,
168 from: "id1".into(),
169 to: "id2".into(),
170 task_id: Some("t-001".into()),
171 context_id: Some("c-001".into()),
172 timestamp: "2026-05-05T21:00:00Z".into(),
173 sequence_id: 1,
174 parts: vec![
175 Part {
176 content: PartContent::Text("hello".into()),
177 },
178 Part {
179 content: PartContent::Data(serde_json::json!({"status": "working"})),
180 },
181 ],
182 };
183
184 let json = serde_json::to_string(&msg).unwrap();
185 let back: MessageEnvelope = serde_json::from_str(&json).unwrap();
186 assert_eq!(back.message_id, "m-001");
187 assert_eq!(back.msg_type, MessageType::Direct);
188 assert_eq!(back.parts.len(), 2);
189 }
190
191 #[test]
192 fn serialize_handoff_data() {
193 let handoff = HandoffData {
194 completion_status: CompletionStatus::Blocked,
195 blocked_reason: Some("need access to sqlitegraph internal API".into()),
196 context_remaining_pct: 28,
197 what_was_done: vec![WhatWasDone {
198 scope: "src/engine.rs".into(),
199 change: "added publish()".into(),
200 verified: true,
201 }],
202 what_is_stubbed: vec![WhatIsStubbed {
203 location: "src/http.rs".into(),
204 reason: "context too low".into(),
205 }],
206 remaining_work: vec!["Implement HTTP server".into()],
207 verification_state: VerificationState {
208 tests_passing: 11,
209 tests_failing: 0,
210 quality_gate: QualityGateResult {
211 passed: true,
212 blocking: 0,
213 warnings: 2,
214 },
215 cargo_check_passed: true,
216 },
217 magellan_trace: MagellanTracePayload {
218 files_changed: vec!["src/engine.rs".into()],
219 symbols_added: vec!["fn publish".into()],
220 symbols_removed: vec![],
221 refs_in: Default::default(),
222 refs_out: Default::default(),
223 },
224 grounded_queries_used: vec!["magellan find --name Engine".into()],
225 };
226
227 let json = serde_json::to_string_pretty(&handoff).unwrap();
228 let back: HandoffData = serde_json::from_str(&json).unwrap();
229 assert_eq!(back.completion_status, CompletionStatus::Blocked);
230 assert_eq!(back.context_remaining_pct, 28);
231 assert!(back.validate().is_ok());
232 }
233
234 #[test]
235 fn handoff_blocked_requires_reason() {
236 let handoff = HandoffData {
237 completion_status: CompletionStatus::Blocked,
238 blocked_reason: None,
239 context_remaining_pct: 50,
240 what_was_done: vec![],
241 what_is_stubbed: vec![],
242 remaining_work: vec![],
243 verification_state: VerificationState {
244 tests_passing: 0,
245 tests_failing: 0,
246 quality_gate: QualityGateResult {
247 passed: true,
248 blocking: 0,
249 warnings: 0,
250 },
251 cargo_check_passed: true,
252 },
253 magellan_trace: MagellanTracePayload {
254 files_changed: vec![],
255 symbols_added: vec![],
256 symbols_removed: vec![],
257 refs_in: Default::default(),
258 refs_out: Default::default(),
259 },
260 grounded_queries_used: vec![],
261 };
262 assert!(handoff.validate().is_err());
263 }
264
265 #[test]
266 fn message_empty_parts_rejected() {
267 let msg = MessageEnvelope {
268 message_id: "m-001".into(),
269 msg_type: MessageType::Direct,
270 from: "id1".into(),
271 to: "id2".into(),
272 task_id: None,
273 context_id: None,
274 timestamp: "now".into(),
275 sequence_id: 1,
276 parts: vec![],
277 };
278 assert!(msg.validate().is_err());
279 }
280
281 #[test]
282 fn message_too_many_parts_rejected() {
283 let parts: Vec<Part> = (0..25)
284 .map(|_| Part {
285 content: PartContent::Text("x".into()),
286 })
287 .collect();
288 let msg = MessageEnvelope {
289 message_id: "m-001".into(),
290 msg_type: MessageType::Direct,
291 from: "id1".into(),
292 to: "id2".into(),
293 task_id: None,
294 context_id: None,
295 timestamp: "now".into(),
296 sequence_id: 1,
297 parts,
298 };
299 assert!(msg.validate().is_err());
300 }
301}