use super::types::{CreateTaskRequest, CreateTaskResponse, MAX_GOAL_LENGTH};
use super::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use lopi_core::{
PermissionMode, PermissionModeError, Priority, ReportChannel, ReportChannelError, Task, TaskId,
};
use lopi_memory::CheckpointInput;
use lopi_spec::SpecSurface;
use serde::Deserialize;
use serde_json::{json, Value};
pub(super) async fn health() -> impl IntoResponse {
Json(json!({ "status": "ok", "service": "lopi" }))
}
pub(super) async fn get_stats(State(s): State<AppState>) -> impl IntoResponse {
let counts = s.store.status_counts().await.unwrap_or_else(|e| {
tracing::warn!("status_counts query failed: {e}");
Default::default()
});
let uptime_secs = s.pool.stats().uptime_secs;
let (total_tokens_today, total_cost_usd_today) =
s.store.daily_token_totals().await.unwrap_or_else(|e| {
tracing::warn!("daily_token_totals query failed: {e}");
(0, 0.0)
});
Json(json!({
"running": counts.running, "queued": counts.queued,
"succeeded": counts.succeeded, "failed": counts.failed,
"uptime_secs": uptime_secs,
"total_tokens_today": total_tokens_today,
"total_cost_usd_today": total_cost_usd_today,
}))
}
pub(super) async fn list_tasks(State(s): State<AppState>) -> Json<Value> {
let rows = s.store.load_history(100).await.unwrap_or_default();
let costs = s.store.task_costs().await.unwrap_or_default();
let body: Vec<_> = rows
.into_iter()
.map(|t| {
let cost = costs.get(&t.id).copied().unwrap_or(0.0);
json!({
"id": t.id, "goal": t.goal, "status": t.status,
"created_at": t.created_at, "completed_at": t.completed_at,
"client_ref": t.client_ref, "cost": cost,
})
})
.collect();
Json(json!({ "tasks": body }))
}
pub(super) async fn get_task(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
let rows = s.store.load_history(500).await.unwrap_or_default();
match find_by_id_prefix(rows, &id, |t| t.id.as_str()) {
PrefixMatch::Unique(t) => {
let cost = s
.store
.task_costs()
.await
.unwrap_or_default()
.get(&t.id)
.copied()
.unwrap_or(0.0);
(
StatusCode::OK,
Json(json!({
"id": t.id, "goal": t.goal, "status": t.status,
"created_at": t.created_at, "completed_at": t.completed_at,
"client_ref": t.client_ref, "cost": cost,
})),
)
.into_response()
}
PrefixMatch::NotFound => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "task not found" })),
)
.into_response(),
PrefixMatch::Ambiguous => ambiguous_prefix_response(),
}
}
enum PrefixMatch<T> {
NotFound,
Unique(T),
Ambiguous,
}
fn find_by_id_prefix<T>(rows: Vec<T>, prefix: &str, id_of: impl Fn(&T) -> &str) -> PrefixMatch<T> {
let mut matches = rows.into_iter().filter(|t| id_of(t).starts_with(prefix));
let Some(first) = matches.next() else {
return PrefixMatch::NotFound;
};
if matches.next().is_some() {
return PrefixMatch::Ambiguous;
}
PrefixMatch::Unique(first)
}
fn ambiguous_prefix_response() -> axum::response::Response {
(
StatusCode::CONFLICT,
Json(json!({ "error": "id prefix matches more than one task" })),
)
.into_response()
}
#[derive(Debug, Deserialize)]
pub(super) struct CheckpointBody {
pub state: String,
#[serde(default)]
pub attempt: u8,
pub last_plan: Option<String>,
pub last_score: Option<String>,
pub repo_path: Option<String>,
pub context_hash: Option<String>,
}
pub(super) async fn checkpoint_agent(
Path(id): Path<String>,
State(s): State<AppState>,
Json(body): Json<CheckpointBody>,
) -> impl IntoResponse {
let Ok(uuid) = id.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "agent id must be a uuid"})),
)
.into_response();
};
let mut input = CheckpointInput::new(TaskId(uuid), body.attempt, body.state);
input.last_plan = body.last_plan;
input.last_score = body.last_score;
input.repo_path = body.repo_path;
input.context_hash = body.context_hash;
match s.store.save_checkpoint(&input).await {
Ok(checkpoint_id) => (
StatusCode::CREATED,
Json(json!({ "checkpoint_id": checkpoint_id, "task_id": id })),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e:#}") })),
)
.into_response(),
}
}
pub(super) async fn cancel_task(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
let rows = s.store.load_history(500).await.unwrap_or_default();
let t = match find_by_id_prefix(rows, &id, |t| t.id.as_str()) {
PrefixMatch::Unique(t) => t,
PrefixMatch::NotFound => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "task not found"})),
)
.into_response();
}
PrefixMatch::Ambiguous => return ambiguous_prefix_response(),
};
let Ok(uuid) = t.id.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid id"})),
)
.into_response();
};
let task_id = TaskId(uuid);
let cancelled = s.pool.cancel(&task_id).await;
let deleted = match s.store.delete_task(&task_id).await {
Ok(removed) => removed,
Err(e) => {
tracing::warn!(error = %e, task_id = %t.id, "delete_task failed");
false
}
};
(
StatusCode::OK,
Json(json!({
"id": t.id,
"cancelled": cancelled,
"deleted": deleted,
})),
)
.into_response()
}
pub(super) async fn approve_plan(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
decide_plan(&s, &id, lopi_core::PlanDecision::Approve).await
}
pub(super) async fn reject_plan(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
decide_plan(&s, &id, lopi_core::PlanDecision::Reject).await
}
async fn decide_plan(
s: &AppState,
id: &str,
decision: lopi_core::PlanDecision,
) -> axum::response::Response {
let task_id = match resolve_task_id(s, id).await {
PrefixMatch::Unique(task_id) => task_id,
PrefixMatch::NotFound => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "task not found"})),
)
.into_response();
}
PrefixMatch::Ambiguous => return ambiguous_prefix_response(),
};
if s.pool.decide_plan(&task_id, decision).await {
(
StatusCode::OK,
Json(json!({"task_id": task_id.0.to_string(), "decision": decision})),
)
.into_response()
} else {
(
StatusCode::CONFLICT,
Json(json!({"error": "task is not awaiting plan approval"})),
)
.into_response()
}
}
async fn resolve_task_id(s: &AppState, id: &str) -> PrefixMatch<TaskId> {
if let Ok(uuid) = id.parse::<uuid::Uuid>() {
return PrefixMatch::Unique(TaskId(uuid));
}
let rows = s.store.load_history(500).await.unwrap_or_default();
match find_by_id_prefix(rows, id, |t| t.id.as_str()) {
PrefixMatch::Unique(t) => match t.id.parse::<uuid::Uuid>() {
Ok(uuid) => PrefixMatch::Unique(TaskId(uuid)),
Err(_) => PrefixMatch::NotFound,
},
PrefixMatch::NotFound => PrefixMatch::NotFound,
PrefixMatch::Ambiguous => PrefixMatch::Ambiguous,
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(super) enum ApplyLoopFieldsError {
#[error(transparent)]
Report(#[from] ReportChannelError),
#[error(transparent)]
PermissionMode(#[from] PermissionModeError),
}
pub(super) fn apply_loop_fields(
task: &mut Task,
req: &CreateTaskRequest,
) -> Result<(), ApplyLoopFieldsError> {
if let Some(report) = &req.report {
ReportChannel::parse(report)?;
task.report = Some(report.clone());
}
if let Some(mode) = &req.permission_mode {
task.permission_mode = PermissionMode::parse(mode)?;
}
if let Some(v) = req.verifier_required {
task.verifier_required = v;
}
if let Some(m) = &req.verifier_model {
task.verifier_model = Some(m.clone());
}
if let Some(e) = &req.verifier_effort {
task.verifier_effort = Some(e.clone());
}
if let Some(n) = req.max_iterations {
task.max_iterations = Some(n);
}
if let Some(m) = &req.model {
task.model = Some(m.clone());
}
if let Some(e) = &req.effort {
task.effort = Some(e.clone());
}
if let Some(d) = req.deliverable {
task.deliverable = Some(d);
}
if let Some(g) = &req.gate {
task.gate = Some(g.clone());
}
if let Some(u) = &req.until {
task.until = Some(u.clone());
}
if let Some(f) = req.on_fail {
task.on_fail = Some(f);
}
if let Some(a) = &req.acceptance {
task.acceptance = Some(a.clone());
}
if let Some(fo) = req.verifier_fail_open {
task.verifier_fail_open = fo;
}
if let Some(b) = req.budget_tokens {
task.budget_tokens = b;
}
if let Some(bo) = &req.budget_override {
task.budget_override = Some(bo.clone());
}
Ok(())
}
pub(super) fn validate_goal(goal: &str) -> Result<(), String> {
if goal.trim().is_empty() {
return Err("goal must not be empty".to_string());
}
if goal.chars().count() > MAX_GOAL_LENGTH {
return Err(format!("goal too long (max {MAX_GOAL_LENGTH} chars)"));
}
super::types::reject_control_chars(goal)
}
pub(super) async fn create_task(
State(s): State<AppState>,
Json(req): Json<CreateTaskRequest>,
) -> impl IntoResponse {
if let Err(reason) = validate_goal(&req.goal) {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": reason })),
)
.into_response();
}
let mut task = Task::new(req.goal.clone());
if let Err(e) = apply_loop_fields(&mut task, &req) {
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({"error": e.to_string()})),
)
.into_response();
}
task.priority = match req.priority.as_deref() {
Some("low") => Priority::Low,
Some("high") => Priority::High,
Some("critical") => Priority::Critical,
_ => Priority::Normal,
};
if let Some(repo) = req.repo {
task.repo_path = Some(std::path::PathBuf::from(repo));
}
if let Some(dirs) = req.allowed_dirs {
task.allowed_dirs = dirs;
}
if let Some(dirs) = req.forbidden_dirs {
task.forbidden_dirs = dirs;
}
if let Some(c) = req.constraints {
task.constraints = c;
}
if let Some(r) = req.max_retries {
task.max_retries = r;
}
task.require_plan_approval = req.require_plan_approval.unwrap_or(false);
task.client_ref = req.client_ref.clone();
let task_id = task.id.0.to_string();
let client_ref = task.client_ref.clone();
let duplicate_of = s.pool.submit(task).await.map(|id| id.0.to_string());
let resp = CreateTaskResponse {
id: task_id,
goal: req.goal,
queued: duplicate_of.is_none(),
duplicate_of,
client_ref,
};
(StatusCode::CREATED, Json(resp)).into_response()
}
pub(super) async fn get_spec(State(s): State<AppState>) -> impl IntoResponse {
let surface = match SpecSurface::load(&s.repo_path) {
Ok(Some(cached)) => cached,
_ => match SpecSurface::extract(&s.repo_path) {
Ok(live) => live,
Err(e) => {
tracing::warn!("spec extract failed: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "spec extraction failed")
.into_response();
}
},
};
Json(serde_json::json!({
"count": surface.len(),
"rust_files_scanned": surface.rust_files_scanned,
"python_files_scanned": surface.python_files_scanned,
"extracted_at": surface.extracted_at,
"items": surface.items,
}))
.into_response()
}