use std::sync::Arc;
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use trusty_common::inference::{ChatMessage, ChatRequest};
use super::inference::{MANAGER_MAX_TOKENS, MANAGER_TEMPERATURE};
use super::status::{PortfolioStatusResponse, load_portfolio_status, rollup_of};
use crate::daemon::state::DaemonState;
const GENERATED_BY_LLM: &str = "llm";
const GENERATED_BY_FALLBACK: &str = "deterministic_fallback";
#[derive(Debug, Deserialize)]
pub struct DigestQuery {
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DigestScope {
Portfolio,
Project(String),
}
impl DigestScope {
fn label(&self) -> String {
match self {
Self::Portfolio => "portfolio".to_string(),
Self::Project(name) => format!("project:{name}"),
}
}
}
pub fn parse_scope(raw: Option<&str>) -> Result<DigestScope, String> {
match raw.map(str::trim) {
None | Some("") | Some("portfolio") => Ok(DigestScope::Portfolio),
Some(other) => {
if let Some(name) = other.strip_prefix("project:") {
let name = name.trim();
if name.is_empty() {
return Err("scope 'project:' requires a project name".to_string());
}
Ok(DigestScope::Project(name.to_string()))
} else {
Err(format!(
"invalid scope '{other}'; expected 'portfolio' or 'project:<name>'"
))
}
}
}
}
#[derive(Debug, Serialize)]
pub struct DigestResponse {
pub scope: String,
pub generated_by: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub narrative: String,
pub status: PortfolioStatusResponse,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
pub fn deterministic_narrative(scope: &str, status: &PortfolioStatusResponse) -> String {
let t = &status.totals;
let last = status
.totals
.last_activity_at
.map(|ts| ts.to_rfc3339())
.unwrap_or_else(|| "none".to_string());
format!(
"[deterministic fallback — no inference provider configured] {scope} rollup:\n\
- Projects: {projects}\n\
- Sessions: {active} active, {provisioning} provisioning, {stopped} stopped, \
{errored} errored ({sessions_total} total)\n\
- Deliverables: {in_progress} in progress, {blocked} blocked, {complete} complete, \
{delivered} delivered, {shipped} shipped ({deliverables_total} total)\n\
- Milestones: {m_in_progress} in progress, {m_complete} complete, {m_shipped} shipped \
({milestones_total} total)\n\
- Most recent activity: {last}",
projects = status.project_count,
active = t.sessions.active,
provisioning = t.sessions.provisioning,
stopped = t.sessions.stopped,
errored = t.sessions.errored,
sessions_total = t.sessions.total,
in_progress = t.deliverables.in_progress,
blocked = t.deliverables.blocked,
complete = t.deliverables.complete,
delivered = t.deliverables.delivered,
shipped = t.deliverables.shipped,
deliverables_total = t.deliverables.total,
m_in_progress = t.milestones.in_progress,
m_complete = t.milestones.complete,
m_shipped = t.milestones.shipped,
milestones_total = t.milestones.total,
)
}
pub fn build_digest_messages(scope: &str, status: &PortfolioStatusResponse) -> Vec<ChatMessage> {
let snapshot = serde_json::to_string_pretty(status)
.unwrap_or_else(|_| "{\"error\":\"snapshot serialization failed\"}".to_string());
let system = "You are the read-only portfolio manager for a software developer running \
many coding sessions across multiple projects. Given a deterministic status \
snapshot, write a concise (3-6 sentence) plain-prose narrative of what is going \
on right now: what has momentum, what is blocked or needs attention, and where \
activity is concentrated. Use ONLY the numbers in the snapshot — never invent \
projects, sessions, or counts. Do not use bullet lists or headings; write flowing \
prose. Do not suggest mutating actions.";
let user = format!("Scope: {scope}\n\nDeterministic status snapshot (JSON):\n{snapshot}");
vec![ChatMessage::system(system), ChatMessage::user(user)]
}
pub async fn manager_digest_route(
State(state): State<Arc<DaemonState>>,
Query(query): Query<DigestQuery>,
) -> impl IntoResponse {
let scope = match parse_scope(query.scope.as_deref()) {
Ok(scope) => scope,
Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
};
let full = match load_portfolio_status(&state).await {
Ok(full) => full,
Err((code, msg)) => return (code, msg).into_response(),
};
let scoped = match &scope {
DigestScope::Portfolio => full,
DigestScope::Project(name) => {
match full.projects.iter().find(|p| &p.project_name == name) {
Some(p) => rollup_of(vec![p.clone()]),
None => {
return (
StatusCode::NOT_FOUND,
format!("project '{name}' is not registered"),
)
.into_response();
}
}
}
};
let scope_label = scope.label();
let (model, adapter) = match state.manager_state().inference().resolve() {
Ok(pair) => pair,
Err(unavailable) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(DigestResponse {
scope: scope_label.clone(),
generated_by: GENERATED_BY_FALLBACK,
model: None,
narrative: deterministic_narrative(&scope_label, &scoped),
status: scoped,
error: Some("inference_unavailable".to_string()),
message: Some(unavailable.to_string()),
}),
)
.into_response();
}
};
let mut request = ChatRequest::new(model.clone(), build_digest_messages(&scope_label, &scoped));
request.max_tokens = Some(MANAGER_MAX_TOKENS);
request.temperature = Some(MANAGER_TEMPERATURE);
match adapter.chat(&request).await {
Ok(response) => match response.first_text().filter(|t| !t.trim().is_empty()) {
Some(narrative) => Json(DigestResponse {
scope: scope_label,
generated_by: GENERATED_BY_LLM,
model: Some(model),
narrative,
status: scoped,
error: None,
message: None,
})
.into_response(),
None => digest_call_failed(scope_label, scoped, "provider returned an empty narrative"),
},
Err(e) => {
tracing::warn!("manager digest inference call failed: {e}");
digest_call_failed(scope_label, scoped, "inference call failed")
}
}
}
fn digest_call_failed(
scope_label: String,
status: PortfolioStatusResponse,
reason: &str,
) -> axum::response::Response {
(
StatusCode::BAD_GATEWAY,
Json(DigestResponse {
scope: scope_label.clone(),
generated_by: GENERATED_BY_FALLBACK,
model: None,
narrative: deterministic_narrative(&scope_label, &status),
status,
error: Some("inference_failed".to_string()),
message: Some(format!(
"{reason}; returning the deterministic rollup — see GET /api/v1/manager/status"
)),
}),
)
.into_response()
}
#[cfg(test)]
#[path = "digest_tests.rs"]
mod tests;