use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use rusqlite::Connection;
use serde::Deserialize;
use serde_json::json;
use std::sync::{Arc, Mutex};
use crate::db::{self, MemoryInput};
#[derive(Clone)]
pub struct AppState {
pub conn: Arc<Mutex<Connection>>,
}
impl AppState {
#[allow(dead_code)]
pub fn new(conn: Connection) -> Self {
Self {
conn: Arc::new(Mutex::new(conn)),
}
}
}
pub fn router(state: AppState) -> Router {
use tower_http::cors::{Any, CorsLayer};
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
.route("/healthz", get(healthz))
.route("/memories", post(create_memory).get(list_memories))
.route("/memories/:id", get(get_memory).delete(delete_memory))
.route("/search", get(search_memories))
.route("/harnesses", get(list_harnesses))
.route("/harnesses/:id", get(get_harness))
.route("/intents", get(list_intents))
.route("/intents/:id/archive", post(archive_intent_handler))
.route("/activity", get(get_activity))
.route("/issues", get(list_issues))
.route("/issues/:id", get(get_issue))
.route("/advice", get(list_advice_pending))
.route("/advice/:id/promote", post(promote_advice_handler))
.route("/advice/:id/dismiss", post(dismiss_advice_handler))
.layer(cors)
.with_state(state)
}
async fn healthz() -> impl IntoResponse {
Json(json!({
"status": "ok",
"service": "asurada",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn create_memory(
State(state): State<AppState>,
Json(input): Json<MemoryInput>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let m = db::memory::insert(&conn, input).map_err(ApiError::from)?;
Ok((StatusCode::CREATED, Json(m)))
}
#[derive(Deserialize)]
struct ListQ {
user_id: String,
#[serde(default)]
scope: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
}
async fn list_memories(
State(state): State<AppState>,
Query(q): Query<ListQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let rows =
db::memory::list(&conn, &q.user_id, q.scope.as_deref(), q.limit).map_err(ApiError::from)?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct GetQ {
user_id: String,
}
async fn get_memory(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let row = db::memory::get(&conn, &q.user_id, &id).map_err(ApiError::from)?;
match row {
Some(m) => Ok(Json(m)),
None => Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "memory not found".into(),
}),
}
}
async fn delete_memory(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let ok = db::memory::soft_delete(&conn, &q.user_id, &id).map_err(ApiError::from)?;
if ok {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "memory not found".into(),
})
}
}
#[derive(Deserialize)]
struct SearchQ {
user_id: String,
q: String,
#[serde(default = "default_limit")]
limit: usize,
}
async fn search_memories(
State(state): State<AppState>,
Query(q): Query<SearchQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let rows = db::memory::search(&conn, &q.user_id, &q.q, q.limit).map_err(ApiError::from)?;
Ok(Json(rows))
}
fn default_limit() -> usize {
20
}
#[derive(Deserialize)]
struct HarnessListQ {
user_id: String,
#[serde(default)]
project: Option<String>,
}
async fn list_harnesses(
State(state): State<AppState>,
Query(q): Query<HarnessListQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let rows =
db::harness::list(&conn, &q.user_id, q.project.as_deref()).map_err(ApiError::from)?;
Ok(Json(rows))
}
async fn get_harness(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let all = db::harness::list(&conn, &q.user_id, None).map_err(ApiError::from)?;
let h = all.into_iter().find(|h| h.id.starts_with(&id));
match h {
Some(h) => Ok(Json(h)),
None => Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "harness not found".into(),
}),
}
}
#[derive(Deserialize)]
struct AdviceListQ {
user_id: String,
#[serde(default)]
project: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
}
async fn list_advice_pending(
State(state): State<AppState>,
Query(q): Query<AdviceListQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let rows = db::advice::list_pending(&conn, &q.user_id, q.project.as_deref(), q.limit)
.map_err(ApiError::from)?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct AdviceActionQ {
user_id: String,
}
async fn promote_advice_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<AdviceActionQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let pending = db::advice::list_pending(&conn, &q.user_id, None, 1000)
.map_err(ApiError::from)?;
let advice_id = pending
.iter()
.find(|a| a.id.starts_with(&id))
.map(|a| a.id.clone())
.ok_or_else(|| ApiError {
status: StatusCode::NOT_FOUND,
message: "pending advice not found".into(),
})?;
let result = crate::pattern::promote_advice(&conn, &q.user_id, &advice_id)
.map_err(ApiError::from)?;
let body = match result {
crate::pattern::PromoteResult::NewIntent(it) => json!({
"kind": "new_intent",
"advice_id": advice_id,
"intent": it,
}),
crate::pattern::PromoteResult::HarnessEvolved {
harness_id,
skill_path,
} => json!({
"kind": "harness_evolved",
"advice_id": advice_id,
"harness_id": harness_id,
"skill_path": skill_path.display().to_string(),
}),
};
Ok(Json(body))
}
#[derive(Deserialize)]
struct IntentListQ {
user_id: String,
#[serde(default)]
project: Option<String>,
#[serde(default)]
strength: Option<String>,
#[serde(default)]
all: bool,
}
async fn list_intents(
State(state): State<AppState>,
Query(q): Query<IntentListQ>,
) -> Result<impl IntentResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let strength_filter = q
.strength
.as_deref()
.and_then(crate::db::intent::Strength::parse);
let rows = if q.all {
crate::db::intent::list_all(&conn, &q.user_id).map_err(ApiError::from)?
} else {
crate::db::intent::list_active(&conn, &q.user_id, q.project.as_deref(), strength_filter)
.map_err(ApiError::from)?
};
Ok(Json(rows))
}
async fn archive_intent_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<GetQ>,
) -> Result<impl IntentResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let all = crate::db::intent::list_all(&conn, &q.user_id).map_err(ApiError::from)?;
let it = all
.into_iter()
.find(|i| i.id.starts_with(&id))
.ok_or_else(|| ApiError {
status: StatusCode::NOT_FOUND,
message: "intent not found".into(),
})?;
crate::db::intent::set_status(&conn, &it.id, crate::db::intent::Status::Archived)
.map_err(ApiError::from)?;
Ok(Json(json!({"archived": it.id})))
}
trait IntentResponse: IntoResponse {}
impl<T: IntoResponse> IntentResponse for T {}
#[derive(Deserialize)]
struct IssueListQ {
user_id: String,
#[serde(default = "default_issue_limit")]
limit: usize,
}
fn default_issue_limit() -> usize {
50
}
async fn list_issues(
State(state): State<AppState>,
Query(q): Query<IssueListQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let rows = crate::db::issue::list(&conn, &q.user_id, q.limit).map_err(ApiError::from)?;
Ok(Json(rows))
}
async fn get_issue(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let all = crate::db::issue::list(&conn, &q.user_id, 1000).map_err(ApiError::from)?;
let it = all.into_iter().find(|i| i.id.starts_with(&id));
match it {
Some(i) => Ok(Json(i)),
None => Err(ApiError {
status: StatusCode::NOT_FOUND,
message: "issue not found".into(),
}),
}
}
#[derive(Deserialize)]
struct ActivityQ {
user_id: String,
#[serde(default = "default_activity_days")]
days: i64,
}
fn default_activity_days() -> i64 {
1
}
async fn get_activity(
State(state): State<AppState>,
Query(q): Query<ActivityQ>,
) -> Result<impl IntoResponse, ApiError> {
use rusqlite::params;
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let since = (chrono::Utc::now() - chrono::Duration::days(q.days)).to_rfc3339();
fn count_event_type(
conn: &rusqlite::Connection,
user_id: &str,
since: &str,
event_type: &str,
) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM events WHERE user_id = ?1 AND event_type = ?2 AND created_at > ?3",
params![user_id, event_type, since],
|r| r.get(0),
)
.unwrap_or(0)
}
fn count_memory_kind(
conn: &rusqlite::Connection,
user_id: &str,
since: &str,
kind: &str,
) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM memories
WHERE user_id = ?1 AND source = 'asurada'
AND json_extract(metadata, '$.kind') = ?2
AND created_at > ?3
AND deleted_at IS NULL",
params![user_id, kind, since],
|r| r.get(0),
)
.unwrap_or(0)
}
let context_injections = count_event_type(&conn, &q.user_id, &since, "signal.context_injection");
let harness_uses = count_event_type(&conn, &q.user_id, &since, "signal.harness_use");
let interventions = count_event_type(&conn, &q.user_id, &since, "signal.intervention");
let redos = count_event_type(&conn, &q.user_id, &since, "signal.redo");
let user_prompts = count_event_type(&conn, &q.user_id, &since, "hook.user_prompt");
let briefs = count_memory_kind(&conn, &q.user_id, &since, "brief");
let reflections_daily = count_memory_kind(&conn, &q.user_id, &since, "reflection_daily");
let reflections_weekly = count_memory_kind(&conn, &q.user_id, &since, "reflection_weekly");
let advice_proposed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM advice WHERE user_id = ?1 AND created_at > ?2",
params![&q.user_id, &since],
|r| r.get(0),
)
.unwrap_or(0);
let intents_added: i64 = conn
.query_row(
"SELECT COUNT(*) FROM intents WHERE user_id = ?1 AND created_at > ?2",
params![&q.user_id, &since],
|r| r.get(0),
)
.unwrap_or(0);
Ok(Json(json!({
"since": since,
"days": q.days,
"user_prompts": user_prompts,
"context_injections": context_injections,
"harness_uses": harness_uses,
"interventions": interventions,
"redos": redos,
"briefs": briefs,
"reflections_daily": reflections_daily,
"reflections_weekly": reflections_weekly,
"advice_proposed": advice_proposed,
"intents_added": intents_added,
})))
}
async fn dismiss_advice_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<AdviceActionQ>,
) -> Result<impl IntoResponse, ApiError> {
let conn = state
.conn
.lock()
.map_err(|e| ApiError::internal(e.to_string()))?;
let pending = db::advice::list_pending(&conn, &q.user_id, None, 1000)
.map_err(ApiError::from)?;
let advice_id = pending
.iter()
.find(|a| a.id.starts_with(&id))
.map(|a| a.id.clone())
.ok_or_else(|| ApiError {
status: StatusCode::NOT_FOUND,
message: "pending advice not found".into(),
})?;
db::advice::confirm(&conn, &q.user_id, &advice_id, "dismiss").map_err(ApiError::from)?;
db::advice::set_state(&conn, &q.user_id, &advice_id, "done").map_err(ApiError::from)?;
Ok(Json(json!({ "kind": "dismissed", "advice_id": advice_id })))
}
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: msg.into(),
}
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
Self::internal(e.to_string())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}