car-server-core 0.30.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Multi-channel fan-out with one shared code (Unit 5 — MC-8).
//!
//! When an approval becomes eligible while MULTIPLE channels are enabled, the
//! daemon mints ONE shared per-approval code ONCE and delivers a prompt
//! carrying that SAME code to EVERY enabled channel's outbound:
//!
//! - **iMessage** renders text + code (`MessagingOrchestrator::send_shared_prompt`),
//!   byte-for-byte the same `outbound_body` grammar as the iMessage-only poll
//!   path (MC-1) — only the code SOURCE differs (shared vs self-minted).
//! - **Slack** renders Block Kit buttons whose `value` carries the `approval_id`
//!   directly, with the shared code in the prompt text for equality
//!   (`SlackAdapter::post_prompt`).
//!
//! The shared code is minted by this coordinator (the same readable `A0`/`B0`
//! scheme the iMessage `CodeMap` uses, so it is simultaneously a valid iMessage
//! text code and a human-readable Slack ref). The global code↔approval mapping
//! stays **channel-free**: the coordinator holds one `approval_id → code` map,
//! with NO per-channel "surfaced-to" tracking (MC-8 forbids a per-channel field
//! on `CodeMap` or `HostState`).
//!
//! **First-reply-wins** is NOT this module's job to enforce — it falls out of
//! the untouched [`crate::host::HostState::resolve_approval`] first-writer-wins
//! idempotent guard: the first inbound decision from ANY channel resolves; a
//! later click/reply on another channel hits the guard and no-ops (exactly one
//! `approval.resolved`, no double-stamp, no false eviction). Both channels
//! resolve through [`crate::approval_core::ApprovalCore::resolve`] →
//! `HostState::resolve_approval`, so there is a single idempotency point.

use std::collections::HashMap;
use std::sync::Arc;

use tokio::sync::Mutex;

use crate::approval_core::ApprovalCore;
use crate::messaging_orchestrator::MessagingOrchestrator;
use crate::slack_adapter::SlackAdapter;

/// The shared, channel-FREE code mint + map. One `approval_id → code` entry per
/// prompted approval, minted ONCE and handed to every enabled channel. No
/// per-channel surfaced-to field (MC-8).
#[derive(Default)]
struct SharedCodeMap {
    id_to_code: HashMap<String, String>,
    /// Monotonic counter feeding the readable `A0`/`B0` suffix — same scheme as
    /// the iMessage `CodeMap` so the shared code is a valid iMessage text code.
    next: u64,
}

impl SharedCodeMap {
    /// Get the existing shared code for `approval_id`, or mint a fresh one.
    /// Returns `(code, is_new)` so the caller knows whether to deliver (a
    /// brand-new code) or skip (already delivered — idempotency).
    fn get_or_mint(&mut self, approval_id: &str) -> (String, bool) {
        if let Some(code) = self.id_to_code.get(approval_id) {
            return (code.clone(), false);
        }
        let n = self.next;
        self.next += 1;
        let letter = (b'A' + (n % 26) as u8) as char;
        let num = n / 26;
        let code = format!("{letter}{num}");
        self.id_to_code.insert(approval_id.to_string(), code.clone());
        (code, true)
    }

    /// Drop the shared code for an approval no longer eligible (resolved /
    /// reaped). Keeps the map bounded over a long daemon uptime.
    fn evict(&mut self, approval_id: &str) {
        self.id_to_code.remove(approval_id);
    }
}

/// Multi-channel fan-out coordinator. Holds the channel-agnostic
/// [`ApprovalCore`] (for the eligible-pending query), the shared code map, and
/// the enabled channels' OUTBOUND senders (iMessage orchestrator and/or Slack
/// adapter). Either channel may be absent (only one enabled) — fan-out reduces
/// to single-channel delivery, still through the one shared-code path.
pub struct FanoutCoordinator {
    core: ApprovalCore,
    codes: Mutex<SharedCodeMap>,
    imessage: Option<Arc<MessagingOrchestrator>>,
    slack: Option<Arc<SlackAdapter>>,
}

impl FanoutCoordinator {
    /// Build a coordinator over the shared core and the enabled channels'
    /// outbound senders. Pass `None` for a channel that is not enabled.
    pub fn new(
        core: ApprovalCore,
        imessage: Option<Arc<MessagingOrchestrator>>,
        slack: Option<Arc<SlackAdapter>>,
    ) -> Self {
        Self {
            core,
            codes: Mutex::new(SharedCodeMap::default()),
            imessage,
            slack,
        }
    }

    /// One outbound tick: for every newly-eligible approval, mint ONE shared
    /// code and deliver a prompt carrying that SAME code to EVERY enabled
    /// channel. Idempotent per approval — an approval already prompted is
    /// skipped (no duplicate prompt on either channel). Stale codes (approvals
    /// no longer eligible) are evicted so the map stays bounded.
    pub async fn observe_and_fanout(&self) {
        let eligible = self.core.eligible_pending().await;

        // Eviction sweep: drop shared codes whose approval is no longer
        // eligible-pending (resolved on either channel, reaped, or pruned).
        {
            let live: std::collections::HashSet<&str> =
                eligible.iter().map(|a| a.id.as_str()).collect();
            let mut codes = self.codes.lock().await;
            let stale: Vec<String> = codes
                .id_to_code
                .keys()
                .filter(|id| !live.contains(id.as_str()))
                .cloned()
                .collect();
            for id in stale {
                codes.evict(&id);
            }
        }

        for approval in &eligible {
            // Mint-or-get the ONE shared code for this approval (idempotency).
            let (code, is_new) = {
                let mut codes = self.codes.lock().await;
                codes.get_or_mint(&approval.id)
            };
            if !is_new {
                continue; // already prompted on every channel
            }
            // Deliver the SAME shared code to every enabled channel's outbound.
            if let Some(imsg) = &self.imessage {
                imsg.send_shared_prompt(approval, &code).await;
            }
            if let Some(slack) = &self.slack {
                slack.post_prompt(&approval.action, &approval.id, &code).await;
            }
        }
    }
}