Skip to main content

atm_core/boundary/
store.rs

1// This Phase AC boundary file mixes seams that are dormant in the library
2// build with helpers still exercised by retained/test callers. Under
3// `--all-targets`, `#[expect(dead_code)]` turns into
4// `unfulfilled_lint_expectations` on the test-reachable subset before the
5// later cutover/deletion work lands, so this branch intentionally keeps
6// `allow(dead_code)` here.
7#![allow(
8    dead_code,
9    reason = "AC.2 internalizes Claude-only storage seams before their later deletion or full consumer cutover."
10)]
11
12use crate::config::AtmConfig;
13use crate::error::AtmError;
14use crate::schema::{AgentMember, HOME_DIR_METADATA_KEY, InboxMessage};
15use crate::types::{AgentName, IsoTimestamp, PaneId, TeamName};
16pub use atm_storage::contract::{RosterHarness, RosterMemberKind};
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use super::mail::DoctorFinding;
23use super::{ReplaySource, sealed};
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct RosterStoreHealthSnapshot {
27    pub team: TeamName,
28    pub member_count: u64,
29    #[serde(default)]
30    pub stale: bool,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub refreshed_at: Option<IsoTimestamp>,
33}
34
35pub type RosterEntry = atm_storage::contract::RosterMember;
36
37pub fn roster_member_record_from_claude_code_member(
38    team_name: TeamName,
39    member: AgentMember,
40) -> RosterEntry {
41    let harness = member
42        .extra
43        .get("harness")
44        .and_then(Value::as_str)
45        .and_then(roster_harness_from_metadata)
46        .unwrap_or(RosterHarness::CodexCli);
47    let recipient_pane_id = member.tmux_pane_id;
48    let mut metadata_json = member.extra;
49    if !member.agent_id.is_empty() {
50        metadata_json.insert(
51            "agentId".to_string(),
52            Value::String(member.agent_id.to_string()),
53        );
54    }
55    if let Some(joined_at) = member.joined_at {
56        metadata_json.insert(
57            "joinedAt".to_string(),
58            Value::Number(serde_json::Number::from(joined_at)),
59        );
60    }
61    if !member.home_dir.is_empty() {
62        metadata_json.insert(
63            HOME_DIR_METADATA_KEY.to_string(),
64            Value::String(member.home_dir.as_path().display().to_string()),
65        );
66    }
67
68    RosterEntry {
69        team_name,
70        agent_name: member.name,
71        member_kind: RosterMemberKind::Permanent,
72        harness,
73        agent_type: member.agent_type,
74        model: member.model,
75        recipient_pane_id,
76        metadata_json,
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub(crate) struct ProjectedRosterEntry {
82    pub member_name: AgentName,
83    pub harness: RosterHarness,
84    pub inbox_path: Option<PathBuf>,
85    pub tmux_pane_id: Option<PaneId>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub(crate) struct ProjectionRoster {
90    pub team_name: TeamName,
91    pub members: Arc<[ProjectedRosterEntry]>,
92}
93
94impl ProjectionRoster {
95    pub fn from_roster_snapshot(team_name: TeamName, records: &[RosterEntry]) -> Self {
96        let members = records
97            .iter()
98            .map(|record| ProjectedRosterEntry {
99                member_name: record.agent_name.clone(),
100                harness: record.harness,
101                inbox_path: None,
102                tmux_pane_id: record.recipient_pane_id.clone(),
103            })
104            .collect::<Vec<_>>()
105            .into();
106        Self { team_name, members }
107    }
108
109    pub fn contains_member(&self, member: &AgentName) -> bool {
110        self.members
111            .iter()
112            .any(|entry| entry.member_name == *member)
113    }
114}
115
116fn roster_harness_from_metadata(value: &str) -> Option<RosterHarness> {
117    match value {
118        "claude-code" => Some(RosterHarness::ClaudeCode),
119        "codex-cli" => Some(RosterHarness::CodexCli),
120        "gemini-cli" => Some(RosterHarness::GeminiCli),
121        "opencode" => Some(RosterHarness::Opencode),
122        _ => None,
123    }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
127pub struct RosterStoreDoctorReport {
128    pub findings: Vec<DoctorFinding>,
129}
130
131/// Stub config-ingress request for the Phase R skeleton.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct ConfigLoadRequest {
134    pub current_dir: PathBuf,
135}
136
137/// Stub config-ingress response for the Phase R skeleton.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ConfigLoadResponse {
140    pub config: Option<AtmConfig>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
144pub struct ConfigDoctorReport {
145    pub findings: Vec<DoctorFinding>,
146}
147
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "kebab-case")]
150pub enum ProjectionAppendMode {
151    RecoveredLogicalMessageSet,
152}
153
154/// Canonical non-Claude outbound request payload.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct NonClaudeOutboundDeliveryRequest {
157    pub team: TeamName,
158    pub agent: AgentName,
159    pub recipient_pane_id: Option<PaneId>,
160    /// Payload serialized to JSONL must not exceed `MAX_NON_CLAUDE_PAYLOAD_BYTES` (1 MiB),
161    /// enforced by `DaemonNonClaudeOutbound::deliver_payloads` (daemon path) and
162    /// `LocalFileNonClaudeOutbound::deliver_payloads` (CLI path, see service_runtime.rs:218).
163    pub messages: Vec<InboxMessage>,
164}
165
166/// Canonical non-Claude outbound response payload.
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
168pub struct NonClaudeOutboundDeliveryResponse {
169    pub delivered_messages: usize,
170}
171
172/// BOUNDARY-RosterStore — see docs/atm-core/boundaries.md.
173#[deprecated(note = "use canonical atm-storage types instead")]
174pub trait RosterStore: sealed::Sealed {
175    /// # Errors
176    ///
177    /// Returns `AtmError` when roster replacement cannot be applied safely.
178    fn replace_roster(
179        &self,
180        team: &TeamName,
181        members: &[RosterEntry],
182        source: Option<&ReplaySource>,
183    ) -> Result<(), AtmError>;
184    /// # Errors
185    ///
186    /// Returns `AtmError` when one roster snapshot cannot be loaded.
187    fn load_roster(&self, team: &TeamName) -> Result<Vec<RosterEntry>, AtmError>;
188    /// # Errors
189    ///
190    /// Returns `AtmError` when membership cannot be queried.
191    fn query_membership(
192        &self,
193        team: &TeamName,
194        member: &AgentName,
195    ) -> Result<Option<RosterEntry>, AtmError>;
196    /// # Errors
197    ///
198    /// Returns `AtmError` when the canonical roster team set cannot be
199    /// enumerated safely.
200    fn list_teams(&self) -> Result<Vec<TeamName>, AtmError>;
201    /// # Errors
202    ///
203    /// Returns `AtmError` when roster health cannot be collected.
204    fn health_snapshot(&self, team: &TeamName) -> Result<RosterStoreHealthSnapshot, AtmError>;
205}
206
207/// BOUNDARY-RosterStoreDoctor — see docs/atm-core/boundaries.md.
208pub trait RosterStoreDoctor: sealed::Sealed + Send + Sync {
209    /// # Errors
210    ///
211    /// Returns `AtmError` when durable roster-store diagnostics cannot be
212    /// collected or summarized into the shared doctor report shape.
213    fn inspect_roster_store(&self) -> Result<RosterStoreDoctorReport, AtmError>;
214}
215
216/// BOUNDARY-ConfigIngress — see docs/atm-core/boundaries.md.
217pub trait ConfigIngress: sealed::Sealed {
218    /// # Errors
219    ///
220    /// Returns `AtmError` when persisted ATM configuration cannot be loaded,
221    /// parsed, or validated into typed models.
222    fn load_config(&self, request: ConfigLoadRequest) -> Result<ConfigLoadResponse, AtmError>;
223}
224
225/// BOUNDARY-ConfigDoctor — see docs/atm-core/boundaries.md.
226pub trait ConfigDoctor: sealed::Sealed + Send + Sync {
227    /// # Errors
228    ///
229    /// Returns `AtmError` when config diagnostics cannot be collected or
230    /// summarized into the shared doctor report shape.
231    fn inspect_config(&self) -> Result<ConfigDoctorReport, AtmError>;
232}
233
234/// BOUNDARY-NonClaudeOutbound — see docs/atm-core/boundaries.md.
235pub trait NonClaudeOutbound: sealed::Sealed {
236    /// # Errors
237    ///
238    /// Returns `AtmError` when non-Claude logical payload delivery cannot be
239    /// executed through the approved outbound boundary.
240    fn deliver_payloads(
241        &self,
242        request: NonClaudeOutboundDeliveryRequest,
243    ) -> Result<NonClaudeOutboundDeliveryResponse, AtmError>;
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    struct WitnessRosterStoreDoctor;
251    struct WitnessConfigDoctor;
252
253    impl sealed::Sealed for WitnessRosterStoreDoctor {}
254    impl sealed::Sealed for WitnessConfigDoctor {}
255
256    impl RosterStoreDoctor for WitnessRosterStoreDoctor {
257        fn inspect_roster_store(&self) -> Result<RosterStoreDoctorReport, AtmError> {
258            Ok(RosterStoreDoctorReport::default())
259        }
260    }
261
262    impl ConfigDoctor for WitnessConfigDoctor {
263        fn inspect_config(&self) -> Result<ConfigDoctorReport, AtmError> {
264            Ok(ConfigDoctorReport::default())
265        }
266    }
267
268    #[test]
269    fn roster_store_doctor_trait_is_object_safe_and_compiles() {
270        fn assert_object_safe(_doctor: &dyn RosterStoreDoctor) {}
271
272        let witness = WitnessRosterStoreDoctor;
273        assert_object_safe(&witness);
274    }
275
276    #[test]
277    fn config_doctor_trait_is_object_safe_and_compiles() {
278        fn assert_object_safe(_doctor: &dyn ConfigDoctor) {}
279
280        let witness = WitnessConfigDoctor;
281        assert_object_safe(&witness);
282    }
283}