use anyhow::Context;
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use serde::Deserialize;
use serde_json::Value;
use super::DaemonClient;
use super::error::response_or_body_error;
use super::projects::ProjectStatusWire;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeliverableStatusCountsWire {
#[serde(default)]
pub proposed: usize,
#[serde(default)]
pub in_progress: usize,
#[serde(default)]
pub blocked: usize,
#[serde(default)]
pub complete: usize,
#[serde(default)]
pub delivered: usize,
#[serde(default)]
pub shipped: usize,
#[serde(default)]
pub total: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MilestoneStatusCountsWire {
#[serde(default)]
pub proposed: usize,
#[serde(default)]
pub in_progress: usize,
#[serde(default)]
pub complete: usize,
#[serde(default)]
pub shipped: usize,
#[serde(default)]
pub total: usize,
#[serde(default)]
pub dangling_deliverable_refs: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PortfolioTotalsWire {
#[serde(default)]
pub sessions: super::projects::SessionStateCountsWire,
#[serde(default)]
pub deliverables: DeliverableStatusCountsWire,
#[serde(default)]
pub milestones: MilestoneStatusCountsWire,
#[serde(default)]
pub last_activity_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PortfolioStatusWire {
#[serde(default)]
pub project_count: usize,
#[serde(default)]
pub totals: PortfolioTotalsWire,
#[serde(default)]
pub projects: Vec<ProjectStatusWire>,
}
const GENERATED_BY_FALLBACK: &str = "deterministic_fallback";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagerDigestOutcome {
pub narrative: String,
pub fallback: bool,
pub raw: Value,
}
impl ManagerDigestOutcome {
fn from_body(raw: Value) -> Option<Self> {
let narrative = raw.get("narrative").and_then(Value::as_str)?.to_string();
let fallback =
raw.get("generated_by").and_then(Value::as_str) == Some(GENERATED_BY_FALLBACK);
Some(Self {
narrative,
fallback,
raw,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagerChatOutcome {
pub reply: String,
pub conversation_key: String,
pub raw: Value,
}
impl ManagerChatOutcome {
fn from_body(raw: Value, requested_key: &str) -> Option<Self> {
let reply = raw
.get("reply")
.and_then(Value::as_str)
.or_else(|| raw.get("message").and_then(Value::as_str))?
.to_string();
let conversation_key = raw
.get("conversation_key")
.and_then(Value::as_str)
.unwrap_or(requested_key)
.to_string();
Some(Self {
reply,
conversation_key,
raw,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RouteTaskOutcome {
pub project: Option<String>,
pub confidence: f32,
pub rationale: String,
pub resolved_by: String,
pub raw: Value,
}
impl RouteTaskOutcome {
fn from_body(raw: Value) -> Option<Self> {
let resolved_by = raw.get("resolved_by").and_then(Value::as_str)?.to_string();
let project = raw
.get("project")
.and_then(Value::as_str)
.map(str::to_string);
let confidence = raw.get("confidence").and_then(Value::as_f64).unwrap_or(0.0) as f32;
let rationale = raw
.get("rationale")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some(Self {
project,
confidence,
rationale,
resolved_by,
raw,
})
}
}
fn daemon_error_text(status: StatusCode, body_text: &str) -> String {
let trimmed = body_text.trim();
if trimmed.is_empty() {
status.to_string()
} else {
format!("{status}: {trimmed}")
}
}
impl DaemonClient {
pub async fn manager_status(&self) -> anyhow::Result<PortfolioStatusWire> {
let url = "/api/v1/manager/status".to_string();
let resp = self.get(&url).send().await?;
let status: PortfolioStatusWire = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize manager status")?;
Ok(status)
}
async fn manager_endpoint_available(&self, path: &str) -> bool {
let url = "/api/v1/manager/version".to_string();
let Ok(resp) = self.get(&url).send().await else {
return false;
};
if !resp.status().is_success() {
return false;
}
let Ok(body) = resp.json::<Value>().await else {
return false;
};
body.get("endpoints")
.and_then(Value::as_array)
.is_some_and(|endpoints| {
endpoints.iter().any(|ep| {
ep.get("path").and_then(Value::as_str) == Some(path)
&& ep.get("available").and_then(Value::as_bool) == Some(true)
})
})
}
pub async fn manager_digest(
&self,
scope: &str,
) -> anyhow::Result<Option<ManagerDigestOutcome>> {
let url = "/api/v1/manager/digest".to_string();
let resp = self.get(&url).query(&[("scope", scope)]).send().await?;
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if let Ok(body) = serde_json::from_str::<Value>(&body_text)
&& let Some(outcome) = ManagerDigestOutcome::from_body(body)
{
return Ok(Some(outcome));
}
if status == StatusCode::NOT_FOUND {
if !self
.manager_endpoint_available("/api/v1/manager/digest")
.await
{
return Ok(None);
}
anyhow::bail!(daemon_error_text(status, &body_text));
}
anyhow::bail!(daemon_error_text(status, &body_text))
}
pub async fn manager_chat(
&self,
conversation_key: &str,
message: &str,
) -> anyhow::Result<Option<ManagerChatOutcome>> {
let url = "/api/v1/manager/chat".to_string();
let resp = self
.post(&url)
.json(&serde_json::json!({
"conversation_key": conversation_key,
"message": message,
}))
.timeout(super::config::CHAT_REQUEST_TIMEOUT)
.send()
.await?;
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if let Ok(body) = serde_json::from_str::<Value>(&body_text)
&& let Some(outcome) = ManagerChatOutcome::from_body(body, conversation_key)
{
return Ok(Some(outcome));
}
if status == StatusCode::NOT_FOUND {
if !self
.manager_endpoint_available("/api/v1/manager/chat")
.await
{
return Ok(None);
}
anyhow::bail!(daemon_error_text(status, &body_text));
}
anyhow::bail!(daemon_error_text(status, &body_text))
}
pub async fn manager_route_task(&self, text: &str) -> anyhow::Result<Option<RouteTaskOutcome>> {
let url = "/api/v1/manager/route-task".to_string();
let resp = self
.post(&url)
.json(&serde_json::json!({ "text": text }))
.timeout(super::config::CHAT_REQUEST_TIMEOUT)
.send()
.await?;
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
if let Ok(body) = serde_json::from_str::<Value>(&body_text)
&& let Some(outcome) = RouteTaskOutcome::from_body(body)
{
return Ok(Some(outcome));
}
if status == StatusCode::NOT_FOUND {
if !self
.manager_endpoint_available("/api/v1/manager/route-task")
.await
{
return Ok(None);
}
anyhow::bail!(daemon_error_text(status, &body_text));
}
anyhow::bail!(daemon_error_text(status, &body_text))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn portfolio_status_wire_parses_totals_and_projects() {
let json = serde_json::json!({
"project_count": 2,
"totals": {
"sessions": { "provisioning": 1, "active": 2, "stopped": 0,
"errored": 0, "decommissioned": 0, "total": 3 },
"deliverables": { "proposed": 1, "in_progress": 1, "total": 2 },
"milestones": { "complete": 1, "total": 1 },
"last_activity_at": "2026-07-13T12:00:00Z"
},
"projects": [
{ "project_name": "alpha", "repo_url": "u1",
"sessions": { "total": 1 } },
{ "project_name": "beta", "repo_url": "u2",
"sessions": { "total": 2 } }
]
});
let status: PortfolioStatusWire = serde_json::from_value(json).unwrap();
assert_eq!(status.project_count, 2);
assert_eq!(status.totals.sessions.total, 3);
assert_eq!(status.totals.deliverables.total, 2);
assert_eq!(status.totals.milestones.total, 1);
assert!(status.totals.last_activity_at.is_some());
assert_eq!(status.projects.len(), 2);
assert_eq!(status.projects[0].project_name, "alpha");
}
#[test]
fn portfolio_status_wire_tolerates_missing_fields() {
let json = serde_json::json!({});
let status: PortfolioStatusWire = serde_json::from_value(json).unwrap();
assert_eq!(status.project_count, 0);
assert_eq!(status.totals.sessions.total, 0);
assert!(status.projects.is_empty());
}
#[test]
fn manager_digest_outcome_reads_real_daemon_shape() {
let body = serde_json::json!({
"scope": "portfolio",
"generated_by": "llm",
"model": "anthropic/claude-3-5-haiku",
"narrative": "Three projects have active sessions; widget is blocked on review.",
"status": { "project_count": 3, "totals": {}, "projects": [] },
});
let outcome = ManagerDigestOutcome::from_body(body).expect("DigestResponse shape");
assert_eq!(
outcome.narrative,
"Three projects have active sessions; widget is blocked on review."
);
assert!(!outcome.fallback);
}
#[test]
fn manager_digest_outcome_marks_deterministic_fallback() {
let body = serde_json::json!({
"scope": "portfolio",
"generated_by": "deterministic_fallback",
"narrative": "[deterministic fallback — no inference provider configured] portfolio rollup:\n- Projects: 1",
"status": { "project_count": 1, "totals": {}, "projects": [] },
"error": "inference_unavailable",
"message": "no inference provider is configured",
});
let outcome = ManagerDigestOutcome::from_body(body).expect("DigestResponse shape");
assert!(outcome.fallback);
assert!(outcome.narrative.starts_with("[deterministic fallback"));
}
#[test]
fn manager_digest_outcome_none_for_non_digest_shape() {
assert!(ManagerDigestOutcome::from_body(serde_json::json!({ "error": "nope" })).is_none());
}
#[test]
fn manager_chat_outcome_reads_real_daemon_shape() {
let body = serde_json::json!({
"conversation_key": "cli:alice",
"reply": "Everything looks healthy right now.",
"model": "anthropic/claude-3-5-haiku",
"turn_count": 2,
});
let outcome =
ManagerChatOutcome::from_body(body, "cli:alice").expect("ChatReplyBody shape");
assert_eq!(outcome.reply, "Everything looks healthy right now.");
assert_eq!(outcome.conversation_key, "cli:alice");
}
#[test]
fn manager_chat_outcome_surfaces_error_message_on_degrade() {
let body = serde_json::json!({
"error": "inference_unavailable",
"message": "no inference provider is configured",
});
let outcome = ManagerChatOutcome::from_body(body, "cli:bob").expect("chat_error shape");
assert_eq!(outcome.reply, "no inference provider is configured");
assert_eq!(outcome.conversation_key, "cli:bob");
}
#[test]
fn manager_chat_outcome_none_for_non_chat_shape() {
assert!(ManagerChatOutcome::from_body(serde_json::json!({ "ok": true }), "k").is_none());
}
#[test]
fn route_task_outcome_reads_real_daemon_shape() {
let body = serde_json::json!({
"project": "alpha",
"confidence": 0.95,
"rationale": "resolved to 'alpha' by exact name match (confidence 0.95)",
"resolved_by": "resolver",
});
let outcome = RouteTaskOutcome::from_body(body).expect("RouteTaskResponse shape");
assert_eq!(outcome.project.as_deref(), Some("alpha"));
assert!((outcome.confidence - 0.95).abs() < 1e-6);
assert_eq!(outcome.resolved_by, "resolver");
}
#[test]
fn route_task_outcome_reads_no_match() {
let body = serde_json::json!({
"project": null,
"confidence": 0.0,
"rationale": "no registered project matched the task",
"resolved_by": "no_match",
});
let outcome = RouteTaskOutcome::from_body(body).expect("no-match shape");
assert!(outcome.project.is_none());
assert_eq!(outcome.resolved_by, "no_match");
}
#[test]
fn route_task_outcome_none_for_non_route_shape() {
assert!(RouteTaskOutcome::from_body(serde_json::json!({ "error": "nope" })).is_none());
}
}