- Rewrite agent loop as Planning→Executing(N)→Completed state machine with per-step context isolation to prevent token explosion - Split tools and prompts by phase (planning vs execution) - Add advance_step/save_memo tools for step transitions and cross-step memory - Unify LLM interface: remove duplicate types, single chat_with_tools path - Add UTF-8 safe truncation (truncate_str) to prevent panics on Chinese text - Extract CreateForm component, add auto-scroll to execution log - Add report generation with app access URL, non-blocking title generation - Add timer system, file serving, app proxy, exec module - Update Dockerfile with uv, deployment config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
148 lines
4.1 KiB
Rust
148 lines
4.1 KiB
Rust
use std::sync::Arc;
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use serde::Deserialize;
|
|
use crate::AppState;
|
|
use crate::db::Timer;
|
|
|
|
type ApiResult<T> = Result<Json<T>, Response>;
|
|
|
|
fn db_err(e: sqlx::Error) -> Response {
|
|
tracing::error!("Database error: {}", e);
|
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateTimer {
|
|
pub name: String,
|
|
pub interval_secs: i64,
|
|
pub requirement: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateTimer {
|
|
pub name: Option<String>,
|
|
pub interval_secs: Option<i64>,
|
|
pub requirement: Option<String>,
|
|
pub enabled: Option<bool>,
|
|
}
|
|
|
|
pub fn router(state: Arc<AppState>) -> Router {
|
|
Router::new()
|
|
.route("/projects/{id}/timers", get(list_timers).post(create_timer))
|
|
.route("/timers/{id}", get(get_timer).put(update_timer).delete(delete_timer))
|
|
.with_state(state)
|
|
}
|
|
|
|
async fn list_timers(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(project_id): Path<String>,
|
|
) -> ApiResult<Vec<Timer>> {
|
|
sqlx::query_as::<_, Timer>(
|
|
"SELECT * FROM timers WHERE project_id = ? ORDER BY created_at DESC"
|
|
)
|
|
.bind(&project_id)
|
|
.fetch_all(&state.db.pool)
|
|
.await
|
|
.map(Json)
|
|
.map_err(db_err)
|
|
}
|
|
|
|
async fn create_timer(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(project_id): Path<String>,
|
|
Json(input): Json<CreateTimer>,
|
|
) -> ApiResult<Timer> {
|
|
if input.interval_secs < 60 {
|
|
return Err((StatusCode::BAD_REQUEST, "Minimum interval is 60 seconds").into_response());
|
|
}
|
|
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
sqlx::query_as::<_, Timer>(
|
|
"INSERT INTO timers (id, project_id, name, interval_secs, requirement) VALUES (?, ?, ?, ?, ?) RETURNING *"
|
|
)
|
|
.bind(&id)
|
|
.bind(&project_id)
|
|
.bind(&input.name)
|
|
.bind(input.interval_secs)
|
|
.bind(&input.requirement)
|
|
.fetch_one(&state.db.pool)
|
|
.await
|
|
.map(Json)
|
|
.map_err(db_err)
|
|
}
|
|
|
|
async fn get_timer(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(timer_id): Path<String>,
|
|
) -> ApiResult<Timer> {
|
|
sqlx::query_as::<_, Timer>("SELECT * FROM timers WHERE id = ?")
|
|
.bind(&timer_id)
|
|
.fetch_optional(&state.db.pool)
|
|
.await
|
|
.map_err(db_err)?
|
|
.map(Json)
|
|
.ok_or_else(|| (StatusCode::NOT_FOUND, "Timer not found").into_response())
|
|
}
|
|
|
|
async fn update_timer(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(timer_id): Path<String>,
|
|
Json(input): Json<UpdateTimer>,
|
|
) -> ApiResult<Timer> {
|
|
// Fetch existing
|
|
let existing = sqlx::query_as::<_, Timer>("SELECT * FROM timers WHERE id = ?")
|
|
.bind(&timer_id)
|
|
.fetch_optional(&state.db.pool)
|
|
.await
|
|
.map_err(db_err)?;
|
|
|
|
let Some(existing) = existing else {
|
|
return Err((StatusCode::NOT_FOUND, "Timer not found").into_response());
|
|
};
|
|
|
|
let name = input.name.unwrap_or(existing.name);
|
|
let interval_secs = input.interval_secs.unwrap_or(existing.interval_secs);
|
|
let requirement = input.requirement.unwrap_or(existing.requirement);
|
|
let enabled = input.enabled.unwrap_or(existing.enabled);
|
|
|
|
if interval_secs < 60 {
|
|
return Err((StatusCode::BAD_REQUEST, "Minimum interval is 60 seconds").into_response());
|
|
}
|
|
|
|
sqlx::query_as::<_, Timer>(
|
|
"UPDATE timers SET name = ?, interval_secs = ?, requirement = ?, enabled = ? WHERE id = ? RETURNING *"
|
|
)
|
|
.bind(&name)
|
|
.bind(interval_secs)
|
|
.bind(&requirement)
|
|
.bind(enabled)
|
|
.bind(&timer_id)
|
|
.fetch_one(&state.db.pool)
|
|
.await
|
|
.map(Json)
|
|
.map_err(db_err)
|
|
}
|
|
|
|
async fn delete_timer(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(timer_id): Path<String>,
|
|
) -> Result<StatusCode, Response> {
|
|
let result = sqlx::query("DELETE FROM timers WHERE id = ?")
|
|
.bind(&timer_id)
|
|
.execute(&state.db.pool)
|
|
.await
|
|
.map_err(db_err)?;
|
|
|
|
if result.rows_affected() > 0 {
|
|
Ok(StatusCode::NO_CONTENT)
|
|
} else {
|
|
Err((StatusCode::NOT_FOUND, "Timer not found").into_response())
|
|
}
|
|
}
|