#![allow(
dead_code,
reason = "AC.2 internalizes Claude-only storage seams before their later deletion or full consumer cutover."
)]
use crate::config::AtmConfig;
use crate::error::AtmError;
use crate::schema::{AgentMember, HOME_DIR_METADATA_KEY, InboxMessage};
use crate::types::{AgentName, IsoTimestamp, PaneId, TeamName};
pub use atm_storage::contract::{RosterHarness, RosterMemberKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use super::mail::DoctorFinding;
use super::{ReplaySource, sealed};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RosterStoreHealthSnapshot {
pub team: TeamName,
pub member_count: u64,
#[serde(default)]
pub stale: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refreshed_at: Option<IsoTimestamp>,
}
pub type RosterEntry = atm_storage::contract::RosterMember;
pub fn roster_member_record_from_claude_code_member(
team_name: TeamName,
member: AgentMember,
) -> RosterEntry {
let harness = member
.extra
.get("harness")
.and_then(Value::as_str)
.and_then(roster_harness_from_metadata)
.unwrap_or(RosterHarness::CodexCli);
let recipient_pane_id = member.tmux_pane_id;
let mut metadata_json = member.extra;
if !member.agent_id.is_empty() {
metadata_json.insert(
"agentId".to_string(),
Value::String(member.agent_id.to_string()),
);
}
if let Some(joined_at) = member.joined_at {
metadata_json.insert(
"joinedAt".to_string(),
Value::Number(serde_json::Number::from(joined_at)),
);
}
if !member.home_dir.is_empty() {
metadata_json.insert(
HOME_DIR_METADATA_KEY.to_string(),
Value::String(member.home_dir.as_path().display().to_string()),
);
}
RosterEntry {
team_name,
agent_name: member.name,
member_kind: RosterMemberKind::Permanent,
harness,
agent_type: member.agent_type,
model: member.model,
recipient_pane_id,
metadata_json,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectedRosterEntry {
pub member_name: AgentName,
pub harness: RosterHarness,
pub inbox_path: Option<PathBuf>,
pub tmux_pane_id: Option<PaneId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProjectionRoster {
pub team_name: TeamName,
pub members: Arc<[ProjectedRosterEntry]>,
}
impl ProjectionRoster {
pub fn from_roster_snapshot(team_name: TeamName, records: &[RosterEntry]) -> Self {
let members = records
.iter()
.map(|record| ProjectedRosterEntry {
member_name: record.agent_name.clone(),
harness: record.harness,
inbox_path: None,
tmux_pane_id: record.recipient_pane_id.clone(),
})
.collect::<Vec<_>>()
.into();
Self { team_name, members }
}
pub fn contains_member(&self, member: &AgentName) -> bool {
self.members
.iter()
.any(|entry| entry.member_name == *member)
}
}
fn roster_harness_from_metadata(value: &str) -> Option<RosterHarness> {
match value {
"claude-code" => Some(RosterHarness::ClaudeCode),
"codex-cli" => Some(RosterHarness::CodexCli),
"gemini-cli" => Some(RosterHarness::GeminiCli),
"opencode" => Some(RosterHarness::Opencode),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct RosterStoreDoctorReport {
pub findings: Vec<DoctorFinding>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConfigLoadRequest {
pub current_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigLoadResponse {
pub config: Option<AtmConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ConfigDoctorReport {
pub findings: Vec<DoctorFinding>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectionAppendMode {
RecoveredLogicalMessageSet,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NonClaudeOutboundDeliveryRequest {
pub team: TeamName,
pub agent: AgentName,
pub recipient_pane_id: Option<PaneId>,
pub messages: Vec<InboxMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NonClaudeOutboundDeliveryResponse {
pub delivered_messages: usize,
}
#[deprecated(note = "use canonical atm-storage types instead")]
pub trait RosterStore: sealed::Sealed {
fn replace_roster(
&self,
team: &TeamName,
members: &[RosterEntry],
source: Option<&ReplaySource>,
) -> Result<(), AtmError>;
fn load_roster(&self, team: &TeamName) -> Result<Vec<RosterEntry>, AtmError>;
fn query_membership(
&self,
team: &TeamName,
member: &AgentName,
) -> Result<Option<RosterEntry>, AtmError>;
fn list_teams(&self) -> Result<Vec<TeamName>, AtmError>;
fn health_snapshot(&self, team: &TeamName) -> Result<RosterStoreHealthSnapshot, AtmError>;
}
pub trait RosterStoreDoctor: sealed::Sealed + Send + Sync {
fn inspect_roster_store(&self) -> Result<RosterStoreDoctorReport, AtmError>;
}
pub trait ConfigIngress: sealed::Sealed {
fn load_config(&self, request: ConfigLoadRequest) -> Result<ConfigLoadResponse, AtmError>;
}
pub trait ConfigDoctor: sealed::Sealed + Send + Sync {
fn inspect_config(&self) -> Result<ConfigDoctorReport, AtmError>;
}
pub trait NonClaudeOutbound: sealed::Sealed {
fn deliver_payloads(
&self,
request: NonClaudeOutboundDeliveryRequest,
) -> Result<NonClaudeOutboundDeliveryResponse, AtmError>;
}
#[cfg(test)]
mod tests {
use super::*;
struct WitnessRosterStoreDoctor;
struct WitnessConfigDoctor;
impl sealed::Sealed for WitnessRosterStoreDoctor {}
impl sealed::Sealed for WitnessConfigDoctor {}
impl RosterStoreDoctor for WitnessRosterStoreDoctor {
fn inspect_roster_store(&self) -> Result<RosterStoreDoctorReport, AtmError> {
Ok(RosterStoreDoctorReport::default())
}
}
impl ConfigDoctor for WitnessConfigDoctor {
fn inspect_config(&self) -> Result<ConfigDoctorReport, AtmError> {
Ok(ConfigDoctorReport::default())
}
}
#[test]
fn roster_store_doctor_trait_is_object_safe_and_compiles() {
fn assert_object_safe(_doctor: &dyn RosterStoreDoctor) {}
let witness = WitnessRosterStoreDoctor;
assert_object_safe(&witness);
}
#[test]
fn config_doctor_trait_is_object_safe_and_compiles() {
fn assert_object_safe(_doctor: &dyn ConfigDoctor) {}
let witness = WitnessConfigDoctor;
assert_object_safe(&witness);
}
}