zc2 0.0.29

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Summary v1: the JSON contract between `zc agent` and the macOS app and
//! widget (spec §6.3). `docs/agent-api/summary.v1.json` is the fixture both
//! sides test against: a change here that breaks it is a `v` bump.

use serde::{Deserialize, Serialize};

pub const SUMMARY_VERSION: u32 = 1;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Summary {
    pub v: u32,
    pub agent_version: String,
    pub as_of: String,
    pub env: String,
    pub hub: HubStatus,
    pub problems: Vec<Problem>,
    pub account: Option<Account>,
    pub earnings: Option<Earnings>,
    pub prices: Prices,
    pub this_mac: ThisMac,
    pub devices: Option<Vec<Device>>,
    pub devices_online: u32,
    pub devices_total: u32,
    pub links: Links,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HubStatus {
    pub reachable: bool,
    pub last_ok: Option<String>,
    pub error: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Problem {
    pub code: String,
    pub message: String,
    pub hint: Option<String>,
    pub detail: Option<String>,
}

impl Problem {
    /// A problem from the spec §6.3 catalog (plus `broker_crashloop`), with
    /// its user-facing message and hint.
    pub fn new(code: &str) -> Self {
        let (message, hint): (&str, Option<&str>) = match code {
            "not_logged_in" => (
                "This Mac is not signed in to Zakuro",
                Some("Run `zc login`"),
            ),
            "hub_unreachable" => (
                "Can't reach the Zakuro hub",
                Some("Showing the last known values"),
            ),
            "hub_too_old" => (
                "The hub doesn't support the widget yet",
                Some("Local status only until the hub is updated"),
            ),
            "uv_missing" => ("uv is not installed", Some("brew install uv")),
            "zakuro_dir_missing" => (
                "The zakuro worker directory can't be found",
                Some("Clone zak-zakuro into ~/.zakuro/zak-zakuro, or set ZAKURO_WORKER_DIR"),
            ),
            "unmanaged_broker" => (
                "A broker the agent didn't start is running",
                Some("Run `zc down` so the agent can manage sharing"),
            ),
            "port_in_use" => (
                "A port the agent needs is taken",
                Some("No free port for the broker (9000–9010 and a random port were all unavailable)"),
            ),
            "worker_crashloop" => (
                "A worker keeps crashing",
                Some("See ~/.zakuro/agent/workers/"),
            ),
            "drain_timeout" => (
                "A worker took too long to finish its job and was stopped",
                Some("Check the worker's log for a stuck request"),
            ),
            "broker_crashloop" => (
                "The broker keeps crashing",
                Some("Check ~/.zakuro/agent/agent.log"),
            ),
            _ => ("Unexpected problem", None),
        };
        Self {
            code: code.to_string(),
            message: message.to_string(),
            hint: hint.map(str::to_string),
            detail: None,
        }
    }

    pub fn with_detail(mut self, detail: String) -> Self {
        self.detail = Some(detail);
        self
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Account {
    pub username: String,
    pub email: String,
    pub credits_balance: f64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Earnings {
    pub today: f64,
    pub last_7d: f64,
    pub tz: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Prices {
    pub bounds: PriceBounds,
    pub default_per_hour: Option<f64>,
    pub this_mac: ThisMacPrice,
}

/// Integers on the wire (`{"min": 1, "max": 120, "step": 1}`), like the hub's.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PriceBounds {
    pub min: u32,
    pub max: u32,
    pub step: u32,
}

impl Default for PriceBounds {
    fn default() -> Self {
        Self {
            min: 1,
            max: 120,
            step: 1,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThisMacPrice {
    /// One of four values: `"set"` (every one of this Mac's workers has the
    /// same explicit price), `"inherited"` (all of them are `null`, so the
    /// account default applies), `"mixed"` (anything else — `per_hour` is
    /// `null` and `effective_per_hour` is the minimum across workers), or
    /// `"disabled"` (every one of this Mac's workers is disabled on the hub —
    /// `per_hour` and `effective_per_hour` are both `null`).
    pub state: String,
    pub per_hour: Option<f64>,
    pub effective_per_hour: Option<f64>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThisMac {
    pub node_pubkey: String,
    pub name: String,
    pub sharing: bool,
    /// `running` | `starting` | `stopping` | `stopped` | `unmanaged` | `error`.
    pub broker: String,
    pub workers: WorkerCounts,
    pub requests: Requests,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkerCounts {
    pub desired: u32,
    pub running: u32,
    pub busy: u32,
    pub draining: u32,
    pub max: u32,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Requests {
    pub last_5h: u64,
    pub last_1w: u64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Device {
    pub node_pubkey: String,
    pub name: String,
    pub this_mac: bool,
    pub freshness: String,
    pub last_seen_at: Option<String>,
    pub workers: u32,
    pub effective_price_per_hour: Option<PriceRange>,
    pub link: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PriceRange {
    pub min: f64,
    pub max: f64,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Links {
    pub hub: Option<String>,
    pub this_mac: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    const FIXTURE: &str = include_str!("../../docs/agent-api/summary.v1.json");
    const PK: &str = "q8X2n0Lr4mH6vB9sK3wT7yP1cF5gJ0dZ2aE4uR8iO6s=";
    const LINK: &str = "https://stg.hub.zakuro-ai.com/provide?device=a1b2c3d4-w0";

    fn fixture_summary() -> Summary {
        Summary {
            v: SUMMARY_VERSION,
            agent_version: "0.0.26".into(),
            as_of: "2026-09-13T01:42:00Z".into(),
            env: "staging".into(),
            hub: HubStatus {
                reachable: true,
                last_ok: Some("2026-09-13T01:41:30Z".into()),
                error: None,
            },
            problems: vec![Problem::new("uv_missing")],
            account: Some(Account {
                username: "jean".into(),
                email: "jean@zakuro-ai.com".into(),
                credits_balance: 1240.5,
            }),
            earnings: Some(Earnings {
                today: 12.4,
                last_7d: 88.1,
                tz: "Asia/Tokyo".into(),
            }),
            prices: Prices {
                bounds: PriceBounds::default(),
                default_per_hour: Some(18.0),
                this_mac: ThisMacPrice {
                    state: "set".into(),
                    per_hour: Some(18.0),
                    effective_per_hour: Some(18.0),
                },
            },
            this_mac: ThisMac {
                node_pubkey: PK.into(),
                name: "jeans-mbp".into(),
                sharing: true,
                broker: "running".into(),
                workers: WorkerCounts {
                    desired: 2,
                    running: 2,
                    busy: 1,
                    draining: 0,
                    max: 5,
                },
                requests: Requests {
                    last_5h: 31,
                    last_1w: 410,
                },
            },
            devices: Some(vec![
                Device {
                    node_pubkey: PK.into(),
                    name: "jeans-mbp".into(),
                    this_mac: true,
                    freshness: "fresh".into(),
                    last_seen_at: Some("2026-09-13T01:41:30Z".into()),
                    workers: 2,
                    effective_price_per_hour: Some(PriceRange {
                        min: 18.0,
                        max: 18.0,
                    }),
                    link: Some(LINK.into()),
                },
                Device {
                    node_pubkey: "Vb3kT9qL2xN7mR4wY8pC1zF6hJ0sD5gA2eU9iO3tK7c=".into(),
                    name: "studio-mini".into(),
                    this_mac: false,
                    freshness: "fresh".into(),
                    last_seen_at: Some("2026-09-13T01:41:12Z".into()),
                    workers: 3,
                    effective_price_per_hour: Some(PriceRange {
                        min: 12.0,
                        max: 20.0,
                    }),
                    link: Some("https://stg.hub.zakuro-ai.com/provide?device=e5f6a7b8-w0".into()),
                },
                // Gone, its only worker offline and disabled: no price, no link.
                Device {
                    node_pubkey: "Hn5wQ2cX8bL4tZ7kP1mV9rG3yS6dF0jA5uE2oI8hN4g=".into(),
                    name: "old-imac".into(),
                    this_mac: false,
                    freshness: "gone".into(),
                    last_seen_at: Some("2026-09-10T08:03:55Z".into()),
                    workers: 1,
                    effective_price_per_hour: None,
                    link: None,
                },
            ]),
            devices_online: 2,
            devices_total: 3,
            links: Links {
                hub: Some("https://stg.hub.zakuro-ai.com/provide".into()),
                this_mac: Some(LINK.into()),
            },
        }
    }

    #[test]
    fn serializes_exactly_to_the_committed_fixture() {
        let ours = serde_json::to_value(fixture_summary()).unwrap();
        let fixture: serde_json::Value = serde_json::from_str(FIXTURE).unwrap();
        assert_eq!(
            ours, fixture,
            "Summary v1 drifted from docs/agent-api/summary.v1.json"
        );
    }

    #[test]
    fn the_fixture_decodes_and_round_trips() {
        let decoded: Summary = serde_json::from_str(FIXTURE).unwrap();
        assert_eq!(decoded, fixture_summary());
    }

    /// The first device exactly as the fixture has always listed it: the macOS
    /// app's tests decode `devices.first`, `last_seen_at` included.
    const FIRST_DEVICE: &str = r#"{"node_pubkey": "q8X2n0Lr4mH6vB9sK3wT7yP1cF5gJ0dZ2aE4uR8iO6s=", "name": "jeans-mbp", "this_mac": true, "freshness": "fresh",
     "last_seen_at": "2026-09-13T01:41:30Z", "workers": 2, "effective_price_per_hour": {"min": 18.0, "max": 18.0},
     "link": "https://stg.hub.zakuro-ai.com/provide?device=a1b2c3d4-w0"}"#;

    /// The fixture obeys what `build_summary` guarantees: `devices_total` is
    /// the length of `devices`, `devices_online` counts the `fresh` ones.
    #[test]
    fn the_fixture_counts_match_its_device_list() {
        let s: Summary = serde_json::from_str(FIXTURE).unwrap();
        let devices = s.devices.as_ref().expect("the fixture lists devices");
        assert_eq!(s.devices_total as usize, devices.len());
        assert_eq!(
            s.devices_online as usize,
            devices.iter().filter(|d| d.freshness == "fresh").count()
        );
        assert_eq!(
            (s.devices_online, s.devices_total),
            (2, 3),
            "the macOS app's tests pin \"2 of 3 online\""
        );
        assert!(
            FIXTURE.contains(FIRST_DEVICE),
            "the first device must stay byte-for-byte"
        );
        assert!(devices[0].this_mac && devices.iter().filter(|d| d.this_mac).count() == 1);
    }

    #[test]
    fn every_problem_code_has_a_message() {
        for code in [
            "not_logged_in",
            "hub_unreachable",
            "hub_too_old",
            "uv_missing",
            "zakuro_dir_missing",
            "unmanaged_broker",
            "port_in_use",
            "worker_crashloop",
            "drain_timeout",
            "broker_crashloop",
        ] {
            let p = Problem::new(code);
            assert_eq!(p.code, code);
            assert!(!p.message.is_empty(), "{code} needs a message");
            assert!(p.hint.is_some(), "{code} needs a hint");
        }
        assert_eq!(
            Problem::new("worker_crashloop")
                .with_detail("boom".into())
                .detail
                .as_deref(),
            Some("boom")
        );
    }
}