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 {
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,
}
#[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 {
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,
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()),
},
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());
}
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"}"#;
#[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")
);
}
}