use crate::server::app::AppState;
use crate::server::errors::error;
use crate::server::routes::query::query_value;
use crate::server::routes::shell;
use crate::server::sse;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::{Json, Router};
use nomoreide_core::agent_sessions::default_store_path;
use nomoreide_core::error_inbox::{service_cwd, IncidentContext};
use nomoreide_core::process_manager::ServiceStatus;
use nomoreide_core::{fix_loop, repro_bundle};
use nomoreide_daemon_client::protocol::{Incident, IncidentPromptEnvelope, IncidentsEnvelope};
use serde::Deserialize;
use serde_json::json;
use std::path::PathBuf;
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/errors", get(list))
.route("/api/errors/stream", get(stream))
.route("/api/errors/:id/prompt", any(prompt))
.route("/api/errors/:id/bundle", any(bundle))
.route("/api/errors/:id/fix", any(fix))
}
#[derive(Deserialize)]
struct ListQuery {
#[serde(default)]
limit: Option<String>,
}
const DEFAULT_INCIDENT_LIMIT: usize = 100;
const MAX_INCIDENT_LIMIT: usize = 200;
async fn list(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
let limit = query
.limit
.and_then(|limit| limit.parse::<usize>().ok())
.filter(|limit| *limit > 0)
.map_or(DEFAULT_INCIDENT_LIMIT, |limit| {
limit.min(MAX_INCIDENT_LIMIT)
});
Json(IncidentsEnvelope {
ok: true,
incidents: state.errors.list(limit).into_iter().map(wire).collect(),
})
.into_response()
}
async fn stream(State(state): State<AppState>) -> Response {
let replay: Vec<Incident> = state
.errors
.list(STREAM_REPLAY)
.into_iter()
.map(wire)
.collect();
sse::stream(
sse::RETRY_AND_PING,
replay
.into_iter()
.map(|i| sse::named("incident", i))
.collect(),
state.errors.events(),
|incident| Some(sse::named("incident", wire(incident))),
)
}
const STREAM_REPLAY: usize = 50;
fn incident_id(raw: &str) -> Option<Option<u64>> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
Some(raw.parse::<u64>().ok())
}
async fn accept(
raw: &str,
method: &Method,
allowed: Method,
uri: &Uri,
) -> Result<Option<u64>, Response> {
let Some(id) = incident_id(raw) else {
return Err(shell::serve_unauthenticated(method.clone(), uri.clone()).await);
};
if method != allowed {
return Err(error(StatusCode::METHOD_NOT_ALLOWED, "Method not allowed"));
}
Ok(id)
}
async fn prompt(
State(state): State<AppState>,
method: Method,
uri: Uri,
Path(raw): Path<String>,
) -> Response {
let id = match accept(&raw, &method, Method::GET, &uri).await {
Ok(id) => id,
Err(response) => return response,
};
let payload = match id {
Some(id) => state.errors.build_prompt(id).await,
None => None,
};
match payload {
Some(payload) => Json(IncidentPromptEnvelope {
ok: true,
incident: wire(payload.incident),
file: payload.file,
prompt: payload.prompt,
})
.into_response(),
None => not_found(),
}
}
async fn bundle(
State(state): State<AppState>,
method: Method,
uri: Uri,
Path(raw): Path<String>,
) -> Response {
let id = match accept(&raw, &method, Method::GET, &uri).await {
Ok(id) => id,
Err(response) => return response,
};
let Some(context) = incident_context(&state, id).await else {
return not_found();
};
let save = query_value(&uri, "save").as_deref() == Some("1");
let status = status_of(&state, &context.service);
match repro_bundle::build(&context, status.as_ref(), &repro_dir(), save).await {
Ok(bundle) => envelope(&bundle),
Err(reason) => error(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
async fn fix(
State(state): State<AppState>,
method: Method,
uri: Uri,
Path(raw): Path<String>,
) -> Response {
let id = match accept(&raw, &method, Method::POST, &uri).await {
Ok(id) => id,
Err(response) => return response,
};
let Some(context) = incident_context(&state, id).await else {
return not_found();
};
let status = status_of(&state, &context.service);
let bundle = match repro_bundle::build(&context, status.as_ref(), &repro_dir(), false).await {
Ok(bundle) => bundle,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason.to_string()),
};
let repo_path = state.workspace_cwd().await;
let prepared = fix_loop::prepare(&bundle, &repo_path, &default_store_path()).await;
ok_with(serde_json::to_value(&prepared))
}
fn ok_with(payload: Result<serde_json::Value, serde_json::Error>) -> Response {
let mut envelope = serde_json::Map::new();
envelope.insert("ok".to_string(), json!(true));
if let Ok(serde_json::Value::Object(object)) = payload {
envelope.extend(object);
}
Json(serde_json::Value::Object(envelope)).into_response()
}
fn envelope(bundle: &nomoreide_core::repro_bundle::ReproBundle) -> Response {
ok_with(serde_json::to_value(bundle))
}
async fn incident_context(state: &AppState, id: Option<u64>) -> Option<IncidentContext> {
let id = id?;
let incident = state
.errors
.list(usize::MAX)
.into_iter()
.find(|incident| incident.id == id)?;
let cwd = daemon_cwd();
let config = state.config_store.load().await.ok()?;
state
.errors
.context(id, &service_cwd(&config, &incident.service, &cwd))
.await
}
fn status_of(state: &AppState, service: &str) -> Option<ServiceStatus> {
state.runtime.service_status(service)
}
fn daemon_cwd() -> String {
std::env::current_dir()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn repro_dir() -> PathBuf {
PathBuf::from(daemon_cwd())
.join(".nomoreide")
.join("repros")
}
fn not_found() -> Response {
error(StatusCode::NOT_FOUND, "Incident not found")
}
fn wire(incident: nomoreide_core::error_inbox::Incident) -> Incident {
Incident {
id: incident.id,
service: incident.service,
level: incident.level,
signature: incident.signature,
title: incident.title,
file: incident.file,
line: incident.line,
first_seen: iso(incident.first_seen),
last_seen: iso(incident.last_seen),
count: incident.count,
log_excerpt: incident.log_excerpt,
}
}
fn iso(at: chrono::DateTime<chrono::Utc>) -> String {
at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}