use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::hook_emit::HookEventPayload;
use crate::{ActivityFilter, ActivitySource, AppState, DaemonEvent};
use super::error::ApiError;
use super::rpc::parse_iso_or_bad_request;
const ACTIVITY_DEFAULT_LIMIT: usize = 50;
const ACTIVITY_MAX_LIMIT: usize = 500;
#[derive(Deserialize, Debug, Default)]
pub(super) struct ActivityQuery {
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
palace: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
since: Option<String>,
#[serde(default)]
until: Option<String>,
}
#[derive(Serialize, Debug)]
pub(super) struct ActivityRow {
id: u64,
timestamp: chrono::DateTime<chrono::Utc>,
source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
palace_id: Option<String>,
event_type: String,
payload: Value,
}
pub(super) async fn activity_handler(
State(state): State<AppState>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<Value>, ApiError> {
let limit = q
.limit
.unwrap_or(ACTIVITY_DEFAULT_LIMIT)
.clamp(1, ACTIVITY_MAX_LIMIT);
let offset = q.offset.unwrap_or(0);
let source = match q.source.as_deref() {
Some(s) => match ActivitySource::parse(s) {
Some(parsed) => Some(parsed),
None => {
return Err(ApiError::bad_request(format!(
"unknown source '{s}'; expected one of http, mcp, hook",
)));
}
},
None => None,
};
let since = parse_iso_or_bad_request(q.since.as_deref(), "since")?;
let until = parse_iso_or_bad_request(q.until.as_deref(), "until")?;
let filter = ActivityFilter {
palace_id: q.palace.filter(|s| !s.is_empty()),
source,
since,
until,
};
let entries = state
.activity_log
.list(&filter, limit, offset)
.map_err(|e| ApiError::internal(format!("activity list: {e:#}")))?;
let total = state
.activity_log
.count()
.map_err(|e| ApiError::internal(format!("activity count: {e:#}")))?;
let rows: Vec<ActivityRow> = entries
.into_iter()
.map(|e| {
let payload = serde_json::from_str::<Value>(&e.payload)
.unwrap_or_else(|_| Value::String(e.payload.clone()));
ActivityRow {
id: e.id,
timestamp: e.timestamp,
source: e.source.as_str(),
palace_id: e.palace_id,
event_type: e.event_type,
payload,
}
})
.collect();
Ok(Json(serde_json::json!({
"entries": rows,
"total": total,
"limit": limit,
"offset": offset,
})))
}
pub(super) async fn hook_activity_handler(
State(state): State<AppState>,
Json(payload): Json<HookEventPayload>,
) -> Result<StatusCode, ApiError> {
state.emit(DaemonEvent::HookFired {
palace_id: payload.palace_id,
palace_name: payload.palace_name,
hook_type: payload.hook_type,
injection_kind: payload.injection_kind,
injection_length: payload.injection_length,
trigger_prompt_excerpt: payload.trigger_prompt_excerpt,
timestamp: chrono::Utc::now(),
duration_ms: payload.duration_ms,
source: ActivitySource::Hook,
});
Ok(StatusCode::NO_CONTENT)
}