agent-team-mail-core 1.3.0

Core library for local agent team mail workflows.
Documentation
use std::path::Path;

use serde_json::Value;

use crate::boundary::{RosterEntry, RosterStore};
use crate::error::AtmError;
use crate::roles::ROLE_TEAM_LEAD;
use crate::schema::agent_member::LEGACY_CWD_METADATA_KEY;
use crate::schema::{AgentMember, HOME_DIR_METADATA_KEY, TeamConfig, canonical_home_dir};
use crate::types::{AgentId, AgentName};

use super::{MemberSummary, MembersList, MembersQuery};

pub(super) fn list_members_from_roster_store(
    roster_store: &dyn RosterStore,
    query: MembersQuery,
) -> Result<MembersList, AtmError> {
    let roster = load_team_roster(roster_store, &query.team)?;
    if roster.is_empty() {
        return Err(AtmError::team_not_found(&query.team));
    }

    Ok(MembersList {
        team: query.team,
        members: ordered_roster_member_summaries(
            &roster,
            query.caller_identity.as_ref(),
            query.live_cwd.as_deref(),
        ),
    })
}

pub(super) fn load_team_roster(
    roster_store: &dyn RosterStore,
    team: &crate::types::TeamName,
) -> Result<Vec<RosterEntry>, AtmError> {
    roster_store.load_roster(team)
}

pub(super) fn project_team_config_from_roster(
    extra: serde_json::Map<String, Value>,
    records: &[RosterEntry],
) -> Result<TeamConfig, AtmError> {
    let mut members = Vec::with_capacity(records.len());
    if let Some(team_lead) = records
        .iter()
        .find(|member| member.agent_name == ROLE_TEAM_LEAD)
    {
        members.push(agent_member_from_roster_record(team_lead)?);
    }
    for record in records {
        if record.agent_name == ROLE_TEAM_LEAD {
            continue;
        }
        members.push(agent_member_from_roster_record(record)?);
    }
    Ok(TeamConfig { members, extra })
}

pub(super) fn ordered_roster_member_summaries(
    records: &[RosterEntry],
    caller_identity: Option<&AgentName>,
    live_cwd: Option<&Path>,
) -> Vec<MemberSummary> {
    let mut members = Vec::with_capacity(records.len());
    if let Some(team_lead) = records
        .iter()
        .find(|member| member.agent_name == ROLE_TEAM_LEAD)
    {
        members.push(member_summary_from_roster(
            team_lead,
            caller_identity,
            live_cwd,
        ));
    }
    for record in records {
        if record.agent_name == ROLE_TEAM_LEAD {
            continue;
        }
        members.push(member_summary_from_roster(
            record,
            caller_identity,
            live_cwd,
        ));
    }
    members
}

fn member_summary_from_roster(
    record: &RosterEntry,
    caller_identity: Option<&AgentName>,
    live_cwd: Option<&Path>,
) -> MemberSummary {
    MemberSummary {
        name: record.agent_name.clone(),
        agent_id: metadata_string(&record.metadata_json, "agentId")
            .unwrap_or_else(|| format!("{}@{}", record.agent_name, record.team_name)),
        agent_type: record.agent_type.to_string(),
        model: record.model.clone(),
        joined_at: metadata_u64(&record.metadata_json, "joinedAt"),
        tmux_pane_id: record.recipient_pane_id.clone(),
        home_dir: canonical_home_dir(&record.metadata_json).unwrap_or_default(),
        live_cwd: runtime_live_cwd(record, caller_identity, live_cwd),
        extra: compatibility_extra_fields(&record.metadata_json),
    }
}

fn agent_member_from_roster_record(record: &RosterEntry) -> Result<AgentMember, AtmError> {
    let mut extra = compatibility_extra_fields(&record.metadata_json);
    Ok(AgentMember {
        name: record.agent_name.clone(),
        agent_id: roster_record_agent_id(record)?,
        agent_type: record.agent_type.clone(),
        model: record.model.clone(),
        joined_at: metadata_u64(&record.metadata_json, "joinedAt"),
        tmux_pane_id: record.recipient_pane_id.clone(),
        home_dir: canonical_home_dir(&record.metadata_json).unwrap_or_default(),
        extra: {
            extra.remove("agentId");
            extra.remove("joinedAt");
            extra.remove(HOME_DIR_METADATA_KEY);
            #[allow(
                deprecated,
                reason = "Phase AD obsolete: derived compatibility field only"
            )]
            extra.remove(LEGACY_CWD_METADATA_KEY);
            extra
        },
    })
}

fn roster_record_agent_id(record: &RosterEntry) -> Result<AgentId, AtmError> {
    let raw_agent_id = metadata_string(&record.metadata_json, "agentId")
        .unwrap_or_else(|| format!("{}@{}", record.agent_name, record.team_name));
    AgentId::new(raw_agent_id.clone()).map_err(|error| {
        AtmError::validation(format!(
            "roster member {}@{} has invalid persisted agentId '{}': {error}",
            record.agent_name, record.team_name, raw_agent_id
        ))
        .with_recovery(
            "Repair the malformed ATM roster row or rerun `atm teams update-member` so ATM rewrites the member metadata with a valid agentId.",
        )
    })
}

fn compatibility_extra_fields(
    metadata_json: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
    let mut extra = metadata_json.clone();
    extra.remove("agentId");
    extra.remove("joinedAt");
    extra.remove(HOME_DIR_METADATA_KEY);
    #[allow(
        deprecated,
        reason = "Phase AD obsolete: derived compatibility field only"
    )]
    extra.remove(LEGACY_CWD_METADATA_KEY);
    extra
}

fn runtime_live_cwd(
    record: &RosterEntry,
    caller_identity: Option<&AgentName>,
    live_cwd: Option<&Path>,
) -> Option<String> {
    match (caller_identity, live_cwd) {
        (Some(identity), Some(path)) if *identity == record.agent_name => {
            Some(path.display().to_string())
        }
        _ => None,
    }
}

fn metadata_string(metadata_json: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
    metadata_json
        .get(key)
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
}

fn metadata_u64(metadata_json: &serde_json::Map<String, Value>, key: &str) -> Option<u64> {
    metadata_json.get(key).and_then(Value::as_u64)
}