Skip to content

Commit aad6a40

Browse files
h4x0rclaude
andcommitted
feat: add metrics/auth REST routes and wire frontend event handling
Backend: - GET /api/metrics returns SpendingSummary, GET /api/metrics/:task_id returns per-task TaskMetrics (new metrics.rs route module) - POST /api/auth/login, GET /api/auth/profile, POST /api/auth/logout cloud auth endpoints (added to cloud.rs) Frontend: - Add MetricsUpdateEvent, BudgetAlertEvent, GateResultEvent to ServerEvent union type - Wire gate_result, metrics_update, budget_alert events in App.tsx handleServerEvent switch - Add handleMetricsUpdate, handleGateResult, handleBudgetAlert actions and gateResults/budgetAlerts state to observability store - Add fetchMetrics() action for REST-based dashboard loading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff87839 commit aad6a40

9 files changed

Lines changed: 466 additions & 4 deletions

File tree

crates/shepherd-server/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
4242
"/api/sessions/:id/fresh",
4343
post(routes::iterm2::fresh_session),
4444
)
45+
.route("/api/auth/login", post(routes::cloud::auth_login))
46+
.route("/api/auth/profile", get(routes::cloud::auth_profile))
47+
.route("/api/auth/logout", post(routes::cloud::auth_logout))
48+
.route("/api/metrics", get(routes::metrics::spending_summary))
49+
.route("/api/metrics/:task_id", get(routes::metrics::task_metrics))
4550
.route("/api/cloud/status", get(routes::cloud::cloud_status))
4651
.route("/api/cloud/balance", get(routes::cloud::cloud_balance))
4752
.route("/api/cloud/costs", get(routes::cloud::cloud_costs))

crates/shepherd-server/src/routes/cloud.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,85 @@ pub async fn cloud_balance(
9191
}))
9292
}
9393

94+
/// Login response — returns the OAuth login URL.
95+
#[derive(Debug, Serialize)]
96+
pub struct LoginResponse {
97+
pub login_url: String,
98+
}
99+
100+
/// POST /api/auth/login — returns the OAuth login URL.
101+
#[tracing::instrument(skip(state))]
102+
pub async fn auth_login(
103+
State(state): State<Arc<AppState>>,
104+
) -> Result<Json<LoginResponse>, (StatusCode, Json<serde_json::Value>)> {
105+
let cloud = state.cloud_client.as_ref().ok_or_else(|| {
106+
(
107+
StatusCode::SERVICE_UNAVAILABLE,
108+
Json(serde_json::json!({
109+
"error": "Cloud features not available"
110+
})),
111+
)
112+
})?;
113+
114+
Ok(Json(LoginResponse {
115+
login_url: cloud.login_url(None, None),
116+
}))
117+
}
118+
119+
/// Profile response — user profile information.
120+
#[derive(Debug, Serialize)]
121+
pub struct ProfileResponse {
122+
pub user_id: String,
123+
pub email: Option<String>,
124+
pub display_name: Option<String>,
125+
pub plan: String,
126+
pub credits_balance: u32,
127+
}
128+
129+
/// GET /api/auth/profile — returns the cached user profile.
130+
#[tracing::instrument]
131+
pub async fn auth_profile() -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)>
132+
{
133+
if !cloud::auth::is_authenticated() {
134+
return Err((
135+
StatusCode::UNAUTHORIZED,
136+
Json(serde_json::json!({
137+
"error": "Not authenticated"
138+
})),
139+
));
140+
}
141+
142+
let profile = cloud::auth::load_cached_profile().ok_or_else(|| {
143+
(
144+
StatusCode::NOT_FOUND,
145+
Json(serde_json::json!({
146+
"error": "No cached profile found"
147+
})),
148+
)
149+
})?;
150+
151+
Ok(Json(ProfileResponse {
152+
user_id: profile.user_id,
153+
email: profile.email,
154+
display_name: profile.github_handle,
155+
plan: profile.plan.to_string(),
156+
credits_balance: profile.credits_balance,
157+
}))
158+
}
159+
160+
/// Logout response.
161+
#[derive(Debug, Serialize)]
162+
pub struct LogoutResponse {
163+
pub success: bool,
164+
}
165+
166+
/// POST /api/auth/logout — clears auth credentials and cached profile.
167+
#[tracing::instrument]
168+
pub async fn auth_logout() -> Json<LogoutResponse> {
169+
let _ = cloud::auth::logout();
170+
Json(LogoutResponse { success: true })
171+
}
172+
94173
/// Feature cost info for the frontend.
95174
#[derive(Debug, Serialize)]
96175
pub struct FeatureCostResponse {
@@ -271,6 +350,69 @@ mod tests {
271350
assert_eq!(features[1]["credits"], 3);
272351
}
273352

353+
#[test]
354+
fn auth_login_response_serialize() {
355+
let resp = LoginResponse {
356+
login_url: "https://api.shepherd.codes/api/auth/login?provider=github".to_string(),
357+
};
358+
let json = serde_json::to_value(&resp).unwrap();
359+
assert_eq!(
360+
json["login_url"],
361+
"https://api.shepherd.codes/api/auth/login?provider=github"
362+
);
363+
// Ensure only expected fields are present
364+
let obj = json.as_object().unwrap();
365+
assert_eq!(obj.len(), 1);
366+
assert!(obj.contains_key("login_url"));
367+
}
368+
369+
#[test]
370+
fn auth_profile_response_serialize() {
371+
let resp = ProfileResponse {
372+
user_id: "u-123".to_string(),
373+
email: Some("user@example.com".to_string()),
374+
display_name: Some("testuser".to_string()),
375+
plan: "pro".to_string(),
376+
credits_balance: 42,
377+
};
378+
let json = serde_json::to_value(&resp).unwrap();
379+
assert_eq!(json["user_id"], "u-123");
380+
assert_eq!(json["email"], "user@example.com");
381+
assert_eq!(json["display_name"], "testuser");
382+
assert_eq!(json["plan"], "pro");
383+
assert_eq!(json["credits_balance"], 42);
384+
// Ensure all expected fields are present
385+
let obj = json.as_object().unwrap();
386+
assert_eq!(obj.len(), 5);
387+
}
388+
389+
#[test]
390+
fn auth_profile_response_serialize_with_nulls() {
391+
let resp = ProfileResponse {
392+
user_id: "u-456".to_string(),
393+
email: None,
394+
display_name: None,
395+
plan: "free".to_string(),
396+
credits_balance: 0,
397+
};
398+
let json = serde_json::to_value(&resp).unwrap();
399+
assert_eq!(json["user_id"], "u-456");
400+
assert!(json["email"].is_null());
401+
assert!(json["display_name"].is_null());
402+
assert_eq!(json["plan"], "free");
403+
assert_eq!(json["credits_balance"], 0);
404+
}
405+
406+
#[test]
407+
fn auth_logout_response_serialize() {
408+
let resp = LogoutResponse { success: true };
409+
let json = serde_json::to_value(&resp).unwrap();
410+
assert!(json["success"].as_bool().unwrap());
411+
let obj = json.as_object().unwrap();
412+
assert_eq!(obj.len(), 1);
413+
assert!(obj.contains_key("success"));
414+
}
415+
274416
#[test]
275417
fn credit_balance_response_all_fields() {
276418
let resp = CreditBalanceResponse {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use axum::{extract::Path, extract::State, http::StatusCode, Json};
2+
use shepherd_core::observability::{self, SpendingSummary, TaskMetrics};
3+
use std::sync::Arc;
4+
5+
use crate::state::AppState;
6+
7+
/// GET /api/metrics — return aggregate spending summary.
8+
#[tracing::instrument(skip(state))]
9+
pub async fn spending_summary(
10+
State(state): State<Arc<AppState>>,
11+
) -> Result<Json<SpendingSummary>, (StatusCode, Json<serde_json::Value>)> {
12+
let db = state.db.lock().await;
13+
let summary = observability::store::get_spending_summary(&db).map_err(|e| {
14+
(
15+
StatusCode::INTERNAL_SERVER_ERROR,
16+
Json(serde_json::json!({ "error": format!("{}", e) })),
17+
)
18+
})?;
19+
Ok(Json(summary))
20+
}
21+
22+
/// GET /api/metrics/:task_id — return metrics for a specific task.
23+
#[tracing::instrument(skip(state))]
24+
pub async fn task_metrics(
25+
State(state): State<Arc<AppState>>,
26+
Path(task_id): Path<i64>,
27+
) -> Result<Json<TaskMetrics>, (StatusCode, Json<serde_json::Value>)> {
28+
let db = state.db.lock().await;
29+
let metrics = observability::store::get_task_metrics(&db, task_id).map_err(|e| {
30+
(
31+
StatusCode::INTERNAL_SERVER_ERROR,
32+
Json(serde_json::json!({ "error": format!("{}", e) })),
33+
)
34+
})?;
35+
match metrics {
36+
Some(m) => Ok(Json(m)),
37+
None => Err((
38+
StatusCode::NOT_FOUND,
39+
Json(serde_json::json!({ "error": format!("No metrics found for task {}", task_id) })),
40+
)),
41+
}
42+
}
43+
44+
#[cfg(test)]
45+
mod tests {
46+
use shepherd_core::observability::{
47+
AgentSpending, ModelSpending, SpendingSummary, TaskMetrics,
48+
};
49+
50+
#[test]
51+
fn spending_summary_response_serialize() {
52+
let summary = SpendingSummary {
53+
total_cost_usd: 1.25,
54+
total_tokens: 50_000,
55+
total_tasks: 3,
56+
total_llm_calls: 10,
57+
by_agent: vec![AgentSpending {
58+
agent_id: "claude".to_string(),
59+
total_cost_usd: 1.25,
60+
total_tokens: 50_000,
61+
task_count: 3,
62+
}],
63+
by_model: vec![ModelSpending {
64+
model_id: "claude-sonnet-4".to_string(),
65+
total_cost_usd: 1.25,
66+
total_tokens: 50_000,
67+
call_count: 10,
68+
}],
69+
};
70+
71+
let json = serde_json::to_value(&summary).expect("serialize SpendingSummary");
72+
assert_eq!(json["total_cost_usd"], 1.25);
73+
assert_eq!(json["total_tokens"], 50_000);
74+
assert_eq!(json["total_tasks"], 3);
75+
assert_eq!(json["total_llm_calls"], 10);
76+
77+
let agents = json["by_agent"].as_array().expect("by_agent is array");
78+
assert_eq!(agents.len(), 1);
79+
assert_eq!(agents[0]["agent_id"], "claude");
80+
assert_eq!(agents[0]["task_count"], 3);
81+
82+
let models = json["by_model"].as_array().expect("by_model is array");
83+
assert_eq!(models.len(), 1);
84+
assert_eq!(models[0]["model_id"], "claude-sonnet-4");
85+
assert_eq!(models[0]["call_count"], 10);
86+
}
87+
88+
#[test]
89+
fn task_metrics_response_serialize() {
90+
let metrics = TaskMetrics {
91+
task_id: 42,
92+
agent_id: "codex".to_string(),
93+
model_id: "gpt-4o".to_string(),
94+
total_input_tokens: 10_000,
95+
total_output_tokens: 5_000,
96+
total_tokens: 15_000,
97+
total_cost_usd: 0.75,
98+
llm_calls: 4,
99+
duration_secs: Some(120.5),
100+
status: "done".to_string(),
101+
created_at: "2026-03-20T00:00:00Z".to_string(),
102+
updated_at: "2026-03-20T00:05:00Z".to_string(),
103+
};
104+
105+
let json = serde_json::to_value(&metrics).expect("serialize TaskMetrics");
106+
assert_eq!(json["task_id"], 42);
107+
assert_eq!(json["agent_id"], "codex");
108+
assert_eq!(json["model_id"], "gpt-4o");
109+
assert_eq!(json["total_input_tokens"], 10_000);
110+
assert_eq!(json["total_output_tokens"], 5_000);
111+
assert_eq!(json["total_tokens"], 15_000);
112+
assert_eq!(json["total_cost_usd"], 0.75);
113+
assert_eq!(json["llm_calls"], 4);
114+
assert_eq!(json["duration_secs"], 120.5);
115+
assert_eq!(json["status"], "done");
116+
assert_eq!(json["created_at"], "2026-03-20T00:00:00Z");
117+
assert_eq!(json["updated_at"], "2026-03-20T00:05:00Z");
118+
}
119+
120+
#[test]
121+
fn task_metrics_not_found_response() {
122+
let task_id = 999;
123+
let body = serde_json::json!({ "error": format!("No metrics found for task {}", task_id) });
124+
125+
assert_eq!(
126+
body["error"].as_str().unwrap(),
127+
"No metrics found for task 999"
128+
);
129+
// Verify the shape: single "error" key with a string value
130+
let map = body.as_object().expect("response is a JSON object");
131+
assert_eq!(map.len(), 1, "response should have exactly one key");
132+
assert!(map.contains_key("error"), "key must be 'error'");
133+
assert!(map["error"].is_string(), "error value must be a string");
134+
}
135+
}

crates/shepherd-server/src/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod gates;
33
pub mod health;
44
pub mod iterm2;
55
pub mod logogen;
6+
pub mod metrics;
67
pub mod namegen;
78
pub mod northstar;
89
pub mod pr;

src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ const App: React.FC = () => {
4747
store.dispatchTerminalOutput(event.data.task_id, event.data.data);
4848
break;
4949
case "gate_result":
50+
store.handleGateResult(event.data);
51+
break;
52+
case "metrics_update":
53+
store.handleMetricsUpdate(event.data);
54+
break;
55+
case "budget_alert":
56+
store.handleBudgetAlert(event.data);
57+
break;
5058
case "notification":
5159
break;
5260
}

0 commit comments

Comments
 (0)