1use std::env;
4use std::fmt;
5use std::io::Read;
6use std::io::Write;
7use std::num::NonZeroU64;
8use std::path::Path;
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use interprocess::local_socket::{GenericFilePath, Name, ToFsName};
13use serde::{Deserialize, Serialize};
14
15use crate::ack::{AckOutcome, AckRequest};
16use crate::clear::{ClearOutcome, ClearQuery};
17use crate::doctor::{DoctorQuery, DoctorReport};
18use crate::error::{AtmError, AtmErrorKind};
19use crate::error_codes::AtmErrorCode;
20use crate::home;
21use crate::list::{ListOutcome, ListQuery};
22use crate::read::{PeekQuery, ReadOutcome, ReadQuery};
23use crate::send::{SendOutcome, SendRequest};
24use crate::types::{AgentName, IsoTimestamp, TeamName};
25
26const DAEMON_SOCKET_FILENAME: &str = "atm-daemon.sock";
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum SendRequestEnvelope {
31 Compose(SendRequest),
32 Acknowledge(AckRequest),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum SendResponseEnvelope {
38 Sent(SendOutcome),
39 Acknowledged(AckOutcome),
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub enum RequestEnvelope {
45 Send(SendRequestEnvelope),
46 CompatibilityPreflight(CompatibilityPreflight),
47 Heartbeat(TeamMemberHeartbeatRequest),
48 List(ListQuery),
49 Peek(PeekQuery),
50 Receive(ReadQuery),
51 Clear(ClearQuery),
52 Doctor(DoctorQuery),
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub enum ResponseEnvelope {
58 Send(SendResponseEnvelope),
59 CompatibilityVerdict(CompatibilityVerdict),
60 Heartbeat(TeamMemberHeartbeatResponse),
61 List(ListOutcome),
62 Peek(Box<ReadOutcome>),
63 Receive(Box<ReadOutcome>),
64 Clear(ClearOutcome),
65 Doctor(Box<DoctorReport>),
66 Error(ProtocolErrorEnvelope),
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct ProtocolErrorEnvelope {
72 pub code: AtmErrorCode,
73 pub message: String,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub recovery: Vec<String>,
76}
77
78impl ProtocolErrorEnvelope {
79 pub fn from_error(error: &AtmError) -> Self {
80 Self {
81 code: error.code,
82 message: error.message.clone(),
83 recovery: error.recovery.clone(),
84 }
85 }
86
87 pub fn into_atm_error(self) -> AtmError {
88 let mut error =
89 AtmError::new_with_code(self.code, error_kind_for_code(self.code), self.message);
90 for recovery in self.recovery {
91 error = error.with_recovery(recovery);
92 }
93 error
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(transparent)]
99pub struct ReleaseVersion(String);
100
101impl ReleaseVersion {
102 pub fn parse(value: impl AsRef<str>) -> Result<Self, AtmError> {
103 let value = value
104 .as_ref()
105 .trim()
106 .strip_prefix('v')
107 .unwrap_or(value.as_ref().trim());
108 let mut parts = value.split('.');
109 let valid = (0..3).all(|_| {
110 parts.next().is_some_and(|part| {
111 !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())
112 })
113 }) && parts.next().is_none();
114 if !valid {
115 return Err(AtmError::new_with_code(
116 AtmErrorCode::ClientDaemonVersionIncompatible,
117 AtmErrorKind::DaemonUnavailable,
118 format!("invalid ATM release version `{value}`"),
119 )
120 .with_recovery(
121 "Install a matching released atm and atm-daemon pair before retrying.",
122 ));
123 }
124 Ok(Self(value.to_string()))
125 }
126
127 pub fn current() -> Self {
128 Self::parse(env!("CARGO_PKG_VERSION")).expect("package version must be semver")
129 }
130}
131
132impl fmt::Display for ReleaseVersion {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.write_str(&self.0)
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct CompatibilityPreflight {
140 pub client_release: ReleaseVersion,
141 pub wire_version: u16,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub enum CompatibilityVerdict {
146 Compatible {
147 daemon_release: ReleaseVersion,
148 },
149 Incompatible {
150 client_release: ReleaseVersion,
151 daemon_release: ReleaseVersion,
152 code: AtmErrorCode,
153 },
154}
155
156const fn error_kind_for_code(code: AtmErrorCode) -> AtmErrorKind {
157 if let Some(kind) = structural_error_kind_for_code(code) {
158 kind
159 } else if let Some(kind) = observability_error_kind_for_code(code) {
160 kind
161 } else {
162 validation_error_kind_for_code(code)
163 }
164}
165
166const fn structural_error_kind_for_code(code: AtmErrorCode) -> Option<AtmErrorKind> {
167 match code {
168 AtmErrorCode::ConfigHomeUnavailable
169 | AtmErrorCode::AtmHomeUnresolved
170 | AtmErrorCode::ConfigParseFailed
171 | AtmErrorCode::ConfigRetiredHookMembersKey
172 | AtmErrorCode::ConfigRetiredLegacyHookKeys
173 | AtmErrorCode::ConfigTeamParseFailed
174 | AtmErrorCode::ConfigTeamMissing => Some(AtmErrorKind::Config),
175 AtmErrorCode::IdentityUnavailable
176 | AtmErrorCode::IdentityInvalid
177 | AtmErrorCode::WarningIdentityDrift
178 | AtmErrorCode::IdentityConflict => Some(AtmErrorKind::Identity),
179 AtmErrorCode::MemberAlreadyExists => Some(AtmErrorKind::Validation),
180 AtmErrorCode::MemberNotFound => Some(AtmErrorKind::AgentNotFound),
181 AtmErrorCode::AddressParseFailed => Some(AtmErrorKind::Address),
182 AtmErrorCode::TeamUnavailable | AtmErrorCode::TeamNotFound => {
183 Some(AtmErrorKind::TeamNotFound)
184 }
185 AtmErrorCode::AgentNotFound => Some(AtmErrorKind::AgentNotFound),
186 AtmErrorCode::MailboxReadFailed | AtmErrorCode::WarningMailboxRecordSkipped => {
187 Some(AtmErrorKind::MailboxRead)
188 }
189 AtmErrorCode::MailboxWriteFailed => Some(AtmErrorKind::MailboxWrite),
190 AtmErrorCode::MailboxLockFailed
191 | AtmErrorCode::MailboxLockReadOnlyFilesystem
192 | AtmErrorCode::MailboxLockTimeout
193 | AtmErrorCode::WarningStaleMailboxLock => Some(AtmErrorKind::MailboxLock),
194 AtmErrorCode::FilePolicyRejected | AtmErrorCode::FileReferenceRewriteFailed => {
195 Some(AtmErrorKind::FilePolicy)
196 }
197 AtmErrorCode::InternalError => Some(AtmErrorKind::Internal),
198 AtmErrorCode::SerializationFailed => Some(AtmErrorKind::Serialization),
199 AtmErrorCode::WaitTimeout => Some(AtmErrorKind::Timeout),
200 _ => None,
201 }
202}
203
204const fn observability_error_kind_for_code(code: AtmErrorCode) -> Option<AtmErrorKind> {
205 match code {
206 AtmErrorCode::DaemonUnavailable
207 | AtmErrorCode::RuntimeRootInvalid
208 | AtmErrorCode::RuntimeBootstrapRefused
209 | AtmErrorCode::SocketOverrideForbidden
210 | AtmErrorCode::DaemonMayHaveExecuted
211 | AtmErrorCode::DaemonLifecycleWedge
212 | AtmErrorCode::DaemonLaunchGateRejected
213 | AtmErrorCode::DaemonServingStateRejected
214 | AtmErrorCode::DaemonStaleOwnerRecoveryFailed
215 | AtmErrorCode::DaemonAutoStartFailed
216 | AtmErrorCode::DaemonConnectionSaturated
217 | AtmErrorCode::ClientDaemonVersionIncompatible
218 | AtmErrorCode::DaemonAdvisorySessionAlreadyRegistered
219 | AtmErrorCode::DaemonAdvisorySessionNotRegistered
220 | AtmErrorCode::DaemonAdvisorySessionCleanupFailed
221 | AtmErrorCode::RemoteDeliveryOutcomeUnknown
222 | AtmErrorCode::WarningSqliteHealthDegraded
223 | AtmErrorCode::PostSendAdvisoryDeliveryFailed => Some(AtmErrorKind::DaemonUnavailable),
224 AtmErrorCode::ObservabilityEmitFailed => Some(AtmErrorKind::ObservabilityEmit),
225 AtmErrorCode::ObservabilityQueryFailed => Some(AtmErrorKind::ObservabilityQuery),
226 AtmErrorCode::ObservabilityFollowFailed => Some(AtmErrorKind::ObservabilityFollow),
227 AtmErrorCode::ObservabilityHealthFailed
228 | AtmErrorCode::ObservabilityHealthOk
229 | AtmErrorCode::WarningObservabilityHealthDegraded => {
230 Some(AtmErrorKind::ObservabilityHealth)
231 }
232 AtmErrorCode::ObservabilityBootstrapFailed => Some(AtmErrorKind::ObservabilityBootstrap),
233 _ => None,
234 }
235}
236
237const fn validation_error_kind_for_code(code: AtmErrorCode) -> AtmErrorKind {
238 match code {
239 AtmErrorCode::MessageValidationFailed
240 | AtmErrorCode::SelfAddressedSendInvalid
241 | AtmErrorCode::EmptyNudgeTemplateBody
242 | AtmErrorCode::HelpTopicNotFound
243 | AtmErrorCode::AckInvalidState
244 | AtmErrorCode::ClearInvalidState
245 | AtmErrorCode::WarningInvalidTeamMemberSkipped
246 | AtmErrorCode::WarningMalformedAtmFieldIgnored
247 | AtmErrorCode::WarningOriginInboxEntrySkipped
248 | AtmErrorCode::WarningMissingTeamConfigFallback
249 | AtmErrorCode::WarningSendAlertStateDegraded
250 | AtmErrorCode::WarningRosterDrift
251 | AtmErrorCode::WarningBaselineMemberMissing
252 | AtmErrorCode::WarningRestoreInProgress
253 | AtmErrorCode::WarningHookSkipped
254 | AtmErrorCode::WarningHookExecutionFailed
255 | AtmErrorCode::PostSendPaneMissing
256 | AtmErrorCode::PostSendTmuxSendFailed
257 | AtmErrorCode::PostSendGraftUnavailable
258 | AtmErrorCode::TestFakeTransportInjectionFailed
259 | AtmErrorCode::TeamInvalid
260 | AtmErrorCode::CallerContextRequestInvalid => AtmErrorKind::Validation,
261 _ => AtmErrorKind::Validation,
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct FramePayload {
268 pub request_id: RequestId,
269 pub message_kind: MessageKind,
270 pub flags: u16,
271 pub bytes: Vec<u8>,
272}
273
274pub const MAX_DAEMON_FRAME_BYTES: usize = 1024 * 1024;
276pub const ATM_FRAME_MAGIC: u32 = u32::from_be_bytes(*b"ATMD");
277pub const ATM_FRAME_VERSION_V1: u16 = 1;
278pub const ATM_FRAME_FLAGS_V1: u16 = 0;
279pub const ATM_FRAME_HEADER_BYTES: usize = 22;
280
281static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
284#[serde(transparent)]
285pub struct RequestId(NonZeroU64);
286
287impl RequestId {
288 pub fn new(request_id: u64) -> Result<Self, AtmError> {
289 let request_id = NonZeroU64::new(request_id).ok_or_else(|| {
290 AtmError::validation(
291 "ATM daemon protocol request_id must be non-zero",
292 )
293 .with_recovery(
294 "Retry with a client and daemon build that populate non-zero ATM daemon request ids.",
295 )
296 })?;
297 Ok(Self(request_id))
298 }
299
300 pub const fn into_inner(self) -> u64 {
301 self.0.get()
302 }
303}
304
305impl fmt::Display for RequestId {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 write!(f, "{}", self.0)
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[repr(u16)]
313pub enum MessageKind {
314 SendComposeRequest = 0x0001,
315 SendAcknowledgeRequest = 0x0002,
316 HeartbeatRequest = 0x0003,
317 CompatibilityPreflightRequest = 0x0009,
318 ListRequest = 0x0004,
319 PeekRequest = 0x0005,
320 ReceiveRequest = 0x0006,
321 ClearRequest = 0x0007,
322 DoctorRequest = 0x0008,
323 SendSentResponse = 0x1001,
324 SendAcknowledgedResponse = 0x1002,
325 HeartbeatResponse = 0x1003,
326 CompatibilityVerdictResponse = 0x1009,
327 ListResponse = 0x1004,
328 PeekResponse = 0x1005,
329 ReceiveResponse = 0x1006,
330 ClearResponse = 0x1007,
331 DoctorResponse = 0x1008,
332 ErrorResponse = 0x1fff,
333}
334
335impl MessageKind {
336 pub const fn code(self) -> u16 {
337 self as u16
338 }
339
340 pub const fn is_request(self) -> bool {
341 matches!(
342 self,
343 Self::SendComposeRequest
344 | Self::SendAcknowledgeRequest
345 | Self::HeartbeatRequest
346 | Self::CompatibilityPreflightRequest
347 | Self::ListRequest
348 | Self::PeekRequest
349 | Self::ReceiveRequest
350 | Self::ClearRequest
351 | Self::DoctorRequest
352 )
353 }
354
355 pub const fn is_response(self) -> bool {
356 !self.is_request()
357 }
358}
359
360impl TryFrom<u16> for MessageKind {
361 type Error = AtmError;
362
363 fn try_from(value: u16) -> Result<Self, Self::Error> {
364 let kind = match value {
365 0x0001 => Self::SendComposeRequest,
366 0x0002 => Self::SendAcknowledgeRequest,
367 0x0003 => Self::HeartbeatRequest,
368 0x0009 => Self::CompatibilityPreflightRequest,
369 0x0004 => Self::ListRequest,
370 0x0005 => Self::PeekRequest,
371 0x0006 => Self::ReceiveRequest,
372 0x0007 => Self::ClearRequest,
373 0x0008 => Self::DoctorRequest,
374 0x1001 => Self::SendSentResponse,
375 0x1002 => Self::SendAcknowledgedResponse,
376 0x1003 => Self::HeartbeatResponse,
377 0x1009 => Self::CompatibilityVerdictResponse,
378 0x1004 => Self::ListResponse,
379 0x1005 => Self::PeekResponse,
380 0x1006 => Self::ReceiveResponse,
381 0x1007 => Self::ClearResponse,
382 0x1008 => Self::DoctorResponse,
383 0x1fff => Self::ErrorResponse,
384 _ => {
385 return Err(AtmError::validation(format!(
386 "unsupported ATM daemon frame message kind 0x{value:04x}"
387 ))
388 .with_recovery(
389 "Align the CLI and daemon builds so both sides speak the same ATM daemon protocol version before retrying.",
390 ));
391 }
392 };
393 Ok(kind)
394 }
395}
396
397#[derive(Debug, Clone, Default)]
398pub struct JsonAtmProtocolCodec;
399
400impl crate::boundary::sealed::Sealed for JsonAtmProtocolCodec {}
401
402impl crate::boundary::AtmProtocol for JsonAtmProtocolCodec {
403 fn request_to_frame(
404 &self,
405 request_id: RequestId,
406 request: RequestEnvelope,
407 ) -> Result<FramePayload, AtmError> {
408 request_to_frame_payload(request_id, request)
409 }
410
411 fn request_from_frame(
412 &self,
413 frame: FramePayload,
414 ) -> Result<(RequestId, RequestEnvelope), AtmError> {
415 request_from_frame_payload(frame)
416 }
417
418 fn response_to_frame(
419 &self,
420 request_id: RequestId,
421 response: ResponseEnvelope,
422 ) -> Result<FramePayload, AtmError> {
423 response_to_frame_payload(request_id, response)
424 }
425
426 fn response_from_frame(
427 &self,
428 frame: FramePayload,
429 ) -> Result<(RequestId, ResponseEnvelope), AtmError> {
430 response_from_frame_payload(frame)
431 }
432}
433
434pub fn next_request_id() -> RequestId {
435 loop {
436 let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
437 if let Some(request_id) = NonZeroU64::new(request_id) {
438 return RequestId(request_id);
439 }
440 }
441}
442
443pub fn request_to_frame_payload(
444 request_id: RequestId,
445 request: RequestEnvelope,
446) -> Result<FramePayload, AtmError> {
447 Ok(FramePayload {
448 request_id,
449 message_kind: request_message_kind(&request),
450 flags: ATM_FRAME_FLAGS_V1,
451 bytes: serde_json::to_vec(&request).map_err(AtmError::from)?,
452 })
453}
454
455pub fn request_from_frame_payload(
456 frame: FramePayload,
457) -> Result<(RequestId, RequestEnvelope), AtmError> {
458 if !frame.message_kind.is_request() {
459 return Err(AtmError::validation(format!(
460 "ATM daemon request decoder received non-request message kind 0x{:04x}",
461 frame.message_kind.code()
462 ))
463 .with_recovery(
464 "Align the CLI and daemon builds so both sides agree on request and response packet roles before retrying.",
465 ));
466 }
467 let request = match frame.message_kind {
468 MessageKind::SendComposeRequest
469 | MessageKind::SendAcknowledgeRequest
470 | MessageKind::ListRequest
471 | MessageKind::PeekRequest
472 | MessageKind::ReceiveRequest
473 | MessageKind::ClearRequest => {
474 let value = serde_json::from_slice::<serde_json::Value>(&frame.bytes)
475 .map_err(AtmError::from)?;
476 validate_required_caller_context_fields(frame.message_kind, &value)?;
477 serde_json::from_value(value).map_err(AtmError::from)?
478 }
479 _ => serde_json::from_slice(&frame.bytes).map_err(AtmError::from)?,
480 };
481 Ok((frame.request_id, request))
482}
483
484fn validate_required_caller_context_fields(
485 message_kind: MessageKind,
486 value: &serde_json::Value,
487) -> Result<(), AtmError> {
488 let envelope = value.as_object().ok_or_else(|| {
489 AtmError::caller_context_request_invalid(
490 "daemon request payload must be a JSON object with caller_identity and caller_team",
491 )
492 })?;
493 let payload = caller_context_payload_object(message_kind, envelope)?;
494 parse_required_caller_identity(payload)?;
495 parse_required_caller_team(payload)?;
496 Ok(())
497}
498
499fn caller_context_payload_object(
500 message_kind: MessageKind,
501 envelope: &serde_json::Map<String, serde_json::Value>,
502) -> Result<&serde_json::Map<String, serde_json::Value>, AtmError> {
503 match message_kind {
504 MessageKind::SendComposeRequest => nested_payload_object(envelope, &["Send", "Compose"]),
505 MessageKind::SendAcknowledgeRequest => {
506 nested_payload_object(envelope, &["Send", "Acknowledge"])
507 }
508 MessageKind::ListRequest => nested_payload_object(envelope, &["List"]),
509 MessageKind::PeekRequest => nested_payload_object(envelope, &["Peek"]),
510 MessageKind::ReceiveRequest => nested_payload_object(envelope, &["Receive"]),
511 MessageKind::ClearRequest => nested_payload_object(envelope, &["Clear"]),
512 _ => unreachable!("caller-context validation only runs for caller-owned request kinds"),
513 }
514}
515
516fn nested_payload_object<'a>(
517 object: &'a serde_json::Map<String, serde_json::Value>,
518 path: &[&str],
519) -> Result<&'a serde_json::Map<String, serde_json::Value>, AtmError> {
520 let mut current = object;
521 for key in path {
522 let next = current.get(*key).ok_or_else(|| {
523 AtmError::caller_context_request_invalid(format!(
524 "daemon request payload is missing `{key}` envelope field"
525 ))
526 })?;
527 current = next.as_object().ok_or_else(|| {
528 AtmError::caller_context_request_invalid(format!(
529 "daemon request `{key}` envelope field must be a JSON object"
530 ))
531 })?;
532 }
533 Ok(current)
534}
535
536fn parse_required_caller_identity(
537 object: &serde_json::Map<String, serde_json::Value>,
538) -> Result<AgentName, AtmError> {
539 let value = object.get("caller_identity").ok_or_else(|| {
540 AtmError::caller_context_request_invalid("daemon request is missing caller_identity")
541 })?;
542 let raw = value.as_str().ok_or_else(|| {
543 AtmError::caller_context_request_invalid("daemon request caller_identity must be a string")
544 })?;
545 raw.parse::<AgentName>().map_err(|error| {
546 AtmError::caller_context_request_invalid(format!(
547 "daemon request caller_identity is invalid: {}",
548 error.message
549 ))
550 })
551}
552
553fn parse_required_caller_team(
554 object: &serde_json::Map<String, serde_json::Value>,
555) -> Result<TeamName, AtmError> {
556 let value = object.get("caller_team").ok_or_else(|| {
557 AtmError::caller_context_request_invalid("daemon request is missing caller_team")
558 })?;
559 let raw = value.as_str().ok_or_else(|| {
560 AtmError::caller_context_request_invalid("daemon request caller_team must be a string")
561 })?;
562 raw.parse::<TeamName>().map_err(|error| {
563 AtmError::caller_context_request_invalid(format!(
564 "daemon request caller_team is invalid: {}",
565 error.message
566 ))
567 })
568}
569
570pub fn response_to_frame_payload(
571 request_id: RequestId,
572 response: ResponseEnvelope,
573) -> Result<FramePayload, AtmError> {
574 Ok(FramePayload {
575 request_id,
576 message_kind: response_message_kind(&response),
577 flags: ATM_FRAME_FLAGS_V1,
578 bytes: serde_json::to_vec(&response).map_err(AtmError::from)?,
579 })
580}
581
582pub fn response_from_frame_payload(
583 frame: FramePayload,
584) -> Result<(RequestId, ResponseEnvelope), AtmError> {
585 if !frame.message_kind.is_response() {
586 return Err(AtmError::validation(format!(
587 "ATM daemon response decoder received non-response message kind 0x{:04x}",
588 frame.message_kind.code()
589 ))
590 .with_recovery(
591 "Align the CLI and daemon builds so both sides agree on request and response packet roles before retrying.",
592 ));
593 }
594 let response = serde_json::from_slice(&frame.bytes).map_err(AtmError::from)?;
595 Ok((frame.request_id, response))
596}
597
598pub fn write_frame(
599 writer: &mut impl Write,
600 frame: &FramePayload,
601 write_error: &'static str,
602) -> Result<(), AtmError> {
603 if frame.flags != ATM_FRAME_FLAGS_V1 {
604 return Err(AtmError::validation(format!(
605 "unsupported ATM daemon frame flags 0x{:04x} for version {}",
606 frame.flags, ATM_FRAME_VERSION_V1
607 ))
608 .with_recovery(
609 "Retry with a supported ATM daemon client/server build that uses protocol version 1 flags.",
610 ));
611 }
612 if frame.bytes.len() > MAX_DAEMON_FRAME_BYTES {
613 return Err(AtmError::daemon_unavailable(
614 "daemon frame exceeded the maximum supported size",
615 )
616 .with_recovery(
617 "Reduce the daemon request/response payload size before retrying the ATM command.",
618 ));
619 }
620 let mut header = [0u8; ATM_FRAME_HEADER_BYTES];
621 header[0..4].copy_from_slice(&ATM_FRAME_MAGIC.to_be_bytes());
622 header[4..6].copy_from_slice(&ATM_FRAME_VERSION_V1.to_be_bytes());
623 header[6..8].copy_from_slice(&frame.message_kind.code().to_be_bytes());
624 header[8..10].copy_from_slice(&frame.flags.to_be_bytes());
625 header[10..18].copy_from_slice(&frame.request_id.into_inner().to_be_bytes());
626 header[18..22].copy_from_slice(&(frame.bytes.len() as u32).to_be_bytes());
627 writer
628 .write_all(&header)
629 .and_then(|_| writer.write_all(&frame.bytes))
630 .map_err(|source| AtmError::daemon_unavailable(write_error).with_source(source))
631}
632
633pub fn read_frame(
634 reader: &mut impl Read,
635 read_error: &'static str,
636 oversize_error: &'static str,
637) -> Result<Option<FramePayload>, AtmError> {
638 let Some(header) = read_frame_header(reader, read_error)? else {
639 return Ok(None);
640 };
641
642 let header = decode_frame_header(header, oversize_error)?;
643 Ok(Some(read_frame_payload(reader, header, read_error)?))
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct FrameHeader {
648 pub request_id: RequestId,
649 pub message_kind: MessageKind,
650 pub flags: u16,
651 pub payload_length: usize,
652}
653
654pub fn decode_frame_header(
655 header: [u8; ATM_FRAME_HEADER_BYTES],
656 oversize_error: &'static str,
657) -> Result<FrameHeader, AtmError> {
658 let magic = u32::from_be_bytes(header[0..4].try_into().expect("magic"));
659 if magic != ATM_FRAME_MAGIC {
660 return Err(AtmError::validation(format!(
661 "unsupported ATM daemon frame magic 0x{magic:08x}"
662 ))
663 .with_recovery(
664 "Retry with an ATM client and daemon build that both speak the documented ATM daemon protocol.",
665 ));
666 }
667
668 let version = u16::from_be_bytes(header[4..6].try_into().expect("version"));
669 if version != ATM_FRAME_VERSION_V1 {
670 return Err(AtmError::validation(format!(
671 "unsupported ATM daemon frame version {version}"
672 ))
673 .with_recovery(
674 "Align the CLI and daemon builds so both sides use the same ATM daemon protocol version before retrying.",
675 ));
676 }
677
678 let message_kind =
679 MessageKind::try_from(u16::from_be_bytes(header[6..8].try_into().expect("kind")))?;
680 let flags = u16::from_be_bytes(header[8..10].try_into().expect("flags"));
681 if flags != ATM_FRAME_FLAGS_V1 {
682 return Err(AtmError::validation(format!(
683 "unsupported ATM daemon frame flags 0x{flags:04x} for version {version}"
684 ))
685 .with_recovery(
686 "Retry with a supported ATM daemon client/server build that uses the version-1 flag contract.",
687 ));
688 }
689 let request_id = RequestId::new(u64::from_be_bytes(
690 header[10..18].try_into().expect("request id"),
691 ))?;
692 let payload_length = u32::from_be_bytes(header[18..22].try_into().expect("payload")) as usize;
693 if payload_length > MAX_DAEMON_FRAME_BYTES {
694 return Err(AtmError::daemon_unavailable(oversize_error).with_recovery(
695 "Reduce the daemon request/response payload size before retrying the ATM command.",
696 ));
697 }
698
699 Ok(FrameHeader {
700 request_id,
701 message_kind,
702 flags,
703 payload_length,
704 })
705}
706
707pub fn read_frame_payload(
708 reader: &mut impl Read,
709 header: FrameHeader,
710 read_error: &'static str,
711) -> Result<FramePayload, AtmError> {
712 let mut bytes = vec![0u8; header.payload_length];
713 reader
714 .read_exact(&mut bytes)
715 .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
716 Ok(FramePayload {
717 request_id: header.request_id,
718 message_kind: header.message_kind,
719 flags: header.flags,
720 bytes,
721 })
722}
723
724pub fn read_frame_header(
725 reader: &mut impl Read,
726 read_error: &'static str,
727) -> Result<Option<[u8; ATM_FRAME_HEADER_BYTES]>, AtmError> {
728 let mut header = [0u8; ATM_FRAME_HEADER_BYTES];
729 let read = reader
730 .read(&mut header[..1])
731 .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
732 if read == 0 {
733 return Ok(None);
734 }
735 reader
736 .read_exact(&mut header[1..])
737 .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
738 Ok(Some(header))
739}
740
741fn request_message_kind(request: &RequestEnvelope) -> MessageKind {
742 match request {
743 RequestEnvelope::Send(SendRequestEnvelope::Compose(_)) => MessageKind::SendComposeRequest,
744 RequestEnvelope::Send(SendRequestEnvelope::Acknowledge(_)) => {
745 MessageKind::SendAcknowledgeRequest
746 }
747 RequestEnvelope::CompatibilityPreflight(_) => MessageKind::CompatibilityPreflightRequest,
748 RequestEnvelope::Heartbeat(_) => MessageKind::HeartbeatRequest,
749 RequestEnvelope::List(_) => MessageKind::ListRequest,
750 RequestEnvelope::Peek(_) => MessageKind::PeekRequest,
751 RequestEnvelope::Receive(_) => MessageKind::ReceiveRequest,
752 RequestEnvelope::Clear(_) => MessageKind::ClearRequest,
753 RequestEnvelope::Doctor(_) => MessageKind::DoctorRequest,
754 }
755}
756
757fn response_message_kind(response: &ResponseEnvelope) -> MessageKind {
758 match response {
759 ResponseEnvelope::Send(SendResponseEnvelope::Sent(_)) => MessageKind::SendSentResponse,
760 ResponseEnvelope::Send(SendResponseEnvelope::Acknowledged(_)) => {
761 MessageKind::SendAcknowledgedResponse
762 }
763 ResponseEnvelope::CompatibilityVerdict(_) => MessageKind::CompatibilityVerdictResponse,
764 ResponseEnvelope::Heartbeat(_) => MessageKind::HeartbeatResponse,
765 ResponseEnvelope::List(_) => MessageKind::ListResponse,
766 ResponseEnvelope::Peek(_) => MessageKind::PeekResponse,
767 ResponseEnvelope::Receive(_) => MessageKind::ReceiveResponse,
768 ResponseEnvelope::Clear(_) => MessageKind::ClearResponse,
769 ResponseEnvelope::Doctor(_) => MessageKind::DoctorResponse,
770 ResponseEnvelope::Error(_) => MessageKind::ErrorResponse,
771 }
772}
773
774pub fn read_bounded_stream(
781 stream: &mut impl Read,
782 read_error: &'static str,
783 oversize_error: &'static str,
784) -> Result<Vec<u8>, AtmError> {
785 let mut bytes = Vec::new();
786 let mut chunk = [0u8; 8192];
787 loop {
788 let read = stream
789 .read(&mut chunk)
790 .map_err(|source| AtmError::daemon_unavailable(read_error).with_source(source))?;
791 if read == 0 {
792 return Ok(bytes);
793 }
794 if bytes.len().saturating_add(read) > MAX_DAEMON_FRAME_BYTES {
795 return Err(AtmError::daemon_unavailable(oversize_error).with_recovery(
796 "Reduce the daemon request/response payload size before retrying the ATM command.",
797 ));
798 }
799 bytes.extend_from_slice(&chunk[..read]);
800 }
801}
802
803pub fn daemon_socket_path() -> Result<PathBuf, AtmError> {
809 if env::var_os("ATM_DAEMON_SOCKET").is_some_and(|value| !value.is_empty()) {
810 return Err(AtmError::socket_override_forbidden(
811 "ATM_DAEMON_SOCKET cannot override the host singleton endpoint",
812 )
813 .with_recovery(
814 "Remove ATM_DAEMON_SOCKET and connect through the OS-user ATM daemon endpoint.",
815 ));
816 }
817 Ok(platform_local_ipc_endpoint_path(
818 home::current_host_runtime_scope()?.socket,
819 ))
820}
821
822pub fn daemon_socket_path_from_home(home_dir: &Path) -> PathBuf {
824 platform_local_ipc_endpoint_path(
825 home::host_runtime_dir_from_home(home_dir).join(DAEMON_SOCKET_FILENAME),
826 )
827}
828
829pub fn daemon_local_ipc_name() -> Result<Name<'static>, AtmError> {
836 daemon_local_ipc_name_from_path(&daemon_socket_path()?)
837}
838
839pub fn daemon_local_ipc_name_from_path(endpoint_path: &Path) -> Result<Name<'static>, AtmError> {
847 let normalized = platform_local_ipc_endpoint_path(endpoint_path.to_path_buf());
848 normalized
849 .into_os_string()
850 .to_fs_name::<GenericFilePath>()
851 .map_err(|source| {
852 AtmError::daemon_unavailable(format!(
853 "failed to map daemon local IPC endpoint {} to a supported platform-local IPC name",
854 endpoint_path.display()
855 ))
856 .with_source(source)
857 .with_recovery(
858 "Set ATM_DAEMON_SOCKET to a valid daemon local IPC endpoint and retry the ATM command.",
859 )
860 })
861}
862
863#[cfg(windows)]
864fn platform_local_ipc_endpoint_path(path: PathBuf) -> PathBuf {
865 const WINDOWS_PIPE_PREFIX: &str = r"\\.\pipe\";
866
867 let raw = path.to_string_lossy();
868 if raw.starts_with(WINDOWS_PIPE_PREFIX) {
869 return path;
870 }
871
872 let mut hash = 0xcbf29ce484222325u64;
873 for byte in raw.as_bytes() {
874 hash ^= u64::from(*byte);
875 hash = hash.wrapping_mul(0x100000001b3);
876 }
877
878 let mut leaf = path
879 .file_stem()
880 .map(|value| value.to_string_lossy().into_owned())
881 .unwrap_or_else(|| "atm-daemon".to_string());
882 leaf.retain(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_');
883 if leaf.is_empty() {
884 leaf = "atm-daemon".to_string();
885 }
886
887 PathBuf::from(format!(r"\\.\pipe\atm-{}-{hash:016x}", leaf))
888}
889
890#[cfg(not(windows))]
891fn platform_local_ipc_endpoint_path(path: PathBuf) -> PathBuf {
892 path
893}
894
895#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
897#[serde(rename_all = "snake_case")]
898pub enum NotificationKind {
899 Delivery,
900 #[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
901 ReconcileComplete,
902}
903
904impl fmt::Display for NotificationKind {
905 #[allow(
906 deprecated,
907 reason = "Phase AD obsolete transport strings remain stable for historical reconcile/watch decoding and formatting support."
908 )]
909 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
910 let value = match self {
911 Self::Delivery => "delivery",
912 Self::ReconcileComplete => "reconcile_complete",
913 };
914 f.write_str(value)
915 }
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct NotificationEvent {
920 pub kind: NotificationKind,
921 pub detail: String,
922 pub team: Option<TeamName>,
923 pub agent: Option<AgentName>,
924}
925
926#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
928#[serde(rename_all = "snake_case")]
929pub enum HeartbeatActivity {
930 ActiveToolUse,
931 Idle,
932 SessionEnded,
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
937pub struct TeamMemberHeartbeatRequest {
938 pub team: TeamName,
939 pub member: AgentName,
940 pub pid: u32,
941 pub observed_at: IsoTimestamp,
942 pub activity: HeartbeatActivity,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
947pub struct TeamMemberHeartbeatResponse {
948 pub team: TeamName,
949 pub member: AgentName,
950 pub pid: u32,
951 #[serde(default)]
952 pub pid_changed: bool,
953 pub state: RuntimeMemberState,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub last_active_at: Option<IsoTimestamp>,
956}
957
958#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
960#[serde(rename_all = "snake_case")]
961pub enum RuntimeMemberState {
962 Unknown,
963 IdentityConflict,
964 Offline,
965 Idle,
966 Active,
967}
968
969#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum RuntimeLivenessState {
973 Running,
974 Unavailable,
975}
976
977#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
979#[serde(rename_all = "snake_case")]
980pub enum RuntimeReadinessState {
981 Ready,
982 Degraded,
983 Unavailable,
984}
985
986#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
988pub struct RuntimeStatusCounts {
989 pub active_members: usize,
990 pub idle_members: usize,
991 pub offline_members: usize,
992 pub unknown_members: usize,
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997pub struct RuntimeStatusSnapshot {
998 pub liveness: RuntimeLivenessState,
999 pub readiness: RuntimeReadinessState,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub detail: Option<String>,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub singleton_owner_pid: Option<u32>,
1004 #[serde(default)]
1005 pub degraded_ingest: bool,
1006 #[serde(default)]
1007 pub member_counts: RuntimeStatusCounts,
1008}
1009
1010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1012#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1013pub struct WatchSubscriptionRequest {
1014 pub home_dir: PathBuf,
1015 pub team: TeamName,
1016 pub agent: AgentName,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1021#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1022pub struct WatchEventBatch {
1023 pub paths: Vec<PathBuf>,
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1028#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1029pub struct ReconcileRequest {
1030 pub home_dir: PathBuf,
1031 pub team: TeamName,
1032 pub agent: AgentName,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1037#[deprecated(note = "Phase AD obsolete: historical reconcile/watch only")]
1038pub struct ReconcileResult {
1039 pub observed_paths: usize,
1040 pub imported_sources: usize,
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use std::path::PathBuf;
1046
1047 use super::{
1048 DAEMON_SOCKET_FILENAME, HeartbeatActivity, ProtocolErrorEnvelope, RequestEnvelope,
1049 ResponseEnvelope, RuntimeLivenessState, RuntimeMemberState, RuntimeReadinessState,
1050 RuntimeStatusCounts, RuntimeStatusSnapshot, TeamMemberHeartbeatRequest,
1051 TeamMemberHeartbeatResponse, daemon_socket_path, daemon_socket_path_from_home,
1052 next_request_id, platform_local_ipc_endpoint_path, request_from_frame_payload,
1053 request_to_frame_payload,
1054 };
1055 use crate::error::AtmError;
1056 use crate::error_codes::AtmErrorCode;
1057 use crate::list::ListQuery;
1058 use crate::send::{SendMessageSource, SendRequest};
1059 use crate::test_support::{EnvGuard, TEST_SENDER, TEST_TEAM};
1060 use crate::types::{AgentName, IsoTimestamp, ReadSelection, TeamName};
1061 use serial_test::serial;
1062 use tempfile::TempDir;
1063
1064 #[test]
1065 fn heartbeat_request_envelope_round_trips() {
1066 let observed_at = IsoTimestamp::now();
1067 let request = RequestEnvelope::Heartbeat(TeamMemberHeartbeatRequest {
1068 team: TeamName::from_validated("test-team"),
1069 member: AgentName::from_validated("test-agent"),
1070 pid: 4242,
1071 observed_at,
1072 activity: HeartbeatActivity::ActiveToolUse,
1073 });
1074
1075 let encoded = serde_json::to_vec(&request).expect("encode heartbeat request");
1076 let decoded: RequestEnvelope =
1077 serde_json::from_slice(&encoded).expect("decode heartbeat request");
1078
1079 match decoded {
1080 RequestEnvelope::Heartbeat(decoded) => {
1081 assert_eq!(decoded.team, TeamName::from_validated("test-team"));
1082 assert_eq!(decoded.member, AgentName::from_validated("test-agent"));
1083 assert_eq!(decoded.pid, 4242);
1084 assert_eq!(decoded.observed_at, observed_at);
1085 assert_eq!(decoded.activity, HeartbeatActivity::ActiveToolUse);
1086 }
1087 other => panic!("expected heartbeat request, got {other:?}"),
1088 }
1089 }
1090
1091 #[test]
1092 fn heartbeat_response_envelope_round_trips() {
1093 let last_active_at = IsoTimestamp::now();
1094 let response = ResponseEnvelope::Heartbeat(TeamMemberHeartbeatResponse {
1095 team: TeamName::from_validated("test-team"),
1096 member: AgentName::from_validated("test-agent"),
1097 pid: 4242,
1098 pid_changed: true,
1099 state: RuntimeMemberState::Active,
1100 last_active_at: Some(last_active_at),
1101 });
1102
1103 let encoded = serde_json::to_vec(&response).expect("encode heartbeat response");
1104 let decoded: ResponseEnvelope =
1105 serde_json::from_slice(&encoded).expect("decode heartbeat response");
1106
1107 match decoded {
1108 ResponseEnvelope::Heartbeat(decoded) => {
1109 assert_eq!(decoded.team, TeamName::from_validated("test-team"));
1110 assert_eq!(decoded.member, AgentName::from_validated("test-agent"));
1111 assert_eq!(decoded.pid, 4242);
1112 assert!(decoded.pid_changed);
1113 assert_eq!(decoded.state, RuntimeMemberState::Active);
1114 assert_eq!(decoded.last_active_at, Some(last_active_at));
1115 }
1116 other => panic!("expected heartbeat response, got {other:?}"),
1117 }
1118 }
1119
1120 #[test]
1121 fn runtime_status_snapshot_round_trips() {
1122 let snapshot = RuntimeStatusSnapshot {
1123 liveness: RuntimeLivenessState::Running,
1124 readiness: RuntimeReadinessState::Ready,
1125 detail: Some("runtime cache ready".to_string()),
1126 singleton_owner_pid: Some(777),
1127 degraded_ingest: false,
1128 member_counts: RuntimeStatusCounts {
1129 active_members: 2,
1130 idle_members: 1,
1131 offline_members: 1,
1132 unknown_members: 3,
1133 },
1134 };
1135
1136 let encoded = serde_json::to_vec(&snapshot).expect("encode runtime snapshot");
1137 let decoded: RuntimeStatusSnapshot =
1138 serde_json::from_slice(&encoded).expect("decode runtime snapshot");
1139
1140 assert_eq!(decoded, snapshot);
1141 }
1142
1143 #[test]
1144 fn daemon_socket_path_from_home_uses_atm_home_runtime_subtree() {
1145 let tempdir = TempDir::new().expect("tempdir");
1146 let logical_endpoint =
1147 crate::home::host_runtime_dir_from_home(tempdir.path()).join(DAEMON_SOCKET_FILENAME);
1148
1149 assert_eq!(
1150 daemon_socket_path_from_home(tempdir.path()),
1151 platform_local_ipc_endpoint_path(logical_endpoint)
1152 );
1153 }
1154
1155 #[test]
1156 #[serial(env)]
1157 fn daemon_socket_path_ignores_atm_home() {
1158 let tempdir = TempDir::new().expect("tempdir");
1159 let atm_home = tempdir.path().join("atm-home");
1160 let os_home = tempdir.path().join("os-home");
1161 let _env = EnvGuard::set_many([
1162 ("ATM_HOME", Some(atm_home.to_str().expect("utf8 atm home"))),
1163 ("ATM_DAEMON_SOCKET", None),
1164 ("HOME", Some(os_home.to_str().expect("utf8 os home"))),
1165 ]);
1166
1167 assert_eq!(
1168 daemon_socket_path().expect("daemon socket path"),
1169 platform_local_ipc_endpoint_path(
1170 crate::home::current_host_runtime_scope()
1171 .expect("host scope")
1172 .socket,
1173 )
1174 );
1175 }
1176
1177 #[test]
1178 #[serial(env)]
1179 fn daemon_socket_path_rejects_override() {
1180 let _env = EnvGuard::set_many([("ATM_DAEMON_SOCKET", Some("/tmp/alternate.sock"))]);
1181 assert!(daemon_socket_path().is_err());
1182 }
1183
1184 #[test]
1185 fn protocol_error_envelope_preserves_remote_delivery_outcome_unknown_recovery() {
1186 let error = AtmError::remote_delivery_outcome_unknown(
1187 "remote peer delivery outcome is unknown and replay persistence failed",
1188 )
1189 .with_source(
1190 AtmError::daemon_unavailable("remote replay store is not configured").with_recovery(
1191 "Restore the host-scoped ATM durable replay store before retrying remote delivery so atm-daemon can resume unknown peer handoffs safely.",
1192 ),
1193 );
1194 let envelope = ProtocolErrorEnvelope::from_error(&error);
1195 let round_trip = envelope.into_atm_error();
1196
1197 assert_eq!(round_trip.code, AtmErrorCode::RemoteDeliveryOutcomeUnknown);
1198 assert_eq!(round_trip.message, error.message);
1199 assert_eq!(round_trip.recovery, error.recovery);
1200 }
1201
1202 #[test]
1203 fn protocol_error_envelope_round_trips_member_not_found_as_agent_not_found() {
1204 let error = AtmError::member_not_found(TEST_SENDER, TEST_TEAM);
1205 let envelope = ProtocolErrorEnvelope::from_error(&error);
1206 let round_trip = envelope.into_atm_error();
1207
1208 assert_eq!(round_trip.code, AtmErrorCode::MemberNotFound);
1209 assert!(round_trip.is_agent_not_found());
1210 }
1211
1212 fn test_atm_home_dir() -> PathBuf {
1213 std::env::temp_dir().join("atm-protocol-test-home")
1214 }
1215
1216 fn test_workspace_dir() -> PathBuf {
1217 std::env::temp_dir().join("atm-protocol-test-workspace")
1218 }
1219
1220 #[test]
1221 fn request_from_frame_payload_accepts_nested_send_caller_context() {
1222 let request = RequestEnvelope::Send(super::SendRequestEnvelope::Compose(
1223 SendRequest::new(
1224 test_atm_home_dir(),
1225 test_workspace_dir(),
1226 AgentName::from_validated(TEST_SENDER),
1227 "recipient@test-team",
1228 TeamName::from_validated(TEST_TEAM),
1229 SendMessageSource::Inline("hello".to_string()),
1230 None,
1231 false,
1232 None,
1233 false,
1234 )
1235 .expect("send request"),
1236 ));
1237
1238 let frame = request_to_frame_payload(next_request_id(), request).expect("frame");
1239 let (_request_id, decoded) =
1240 request_from_frame_payload(frame).expect("decode nested send request");
1241
1242 match decoded {
1243 RequestEnvelope::Send(super::SendRequestEnvelope::Compose(request)) => {
1244 assert_eq!(request.caller_identity.as_str(), TEST_SENDER);
1245 assert_eq!(request.caller_team.as_str(), TEST_TEAM);
1246 }
1247 other => panic!("expected send request, got {other:?}"),
1248 }
1249 }
1250
1251 #[test]
1252 fn request_from_frame_payload_accepts_nested_list_caller_context() {
1253 let request = RequestEnvelope::List(
1254 ListQuery::new(
1255 test_atm_home_dir(),
1256 test_workspace_dir(),
1257 AgentName::from_validated(TEST_SENDER),
1258 Some("recipient@test-team"),
1259 TeamName::from_validated(TEST_TEAM),
1260 ReadSelection::Unread,
1261 false,
1262 Some(25),
1263 None,
1264 None,
1265 None,
1266 None,
1267 )
1268 .expect("list query"),
1269 );
1270
1271 let frame = request_to_frame_payload(next_request_id(), request).expect("frame");
1272 let (_request_id, decoded) =
1273 request_from_frame_payload(frame).expect("decode nested list request");
1274
1275 match decoded {
1276 RequestEnvelope::List(query) => {
1277 assert_eq!(query.caller_identity.as_str(), TEST_SENDER);
1278 assert_eq!(query.caller_team.as_str(), TEST_TEAM);
1279 }
1280 other => panic!("expected list request, got {other:?}"),
1281 }
1282 }
1283}