use super::types::{reject_control_chars, MAX_GOAL_LENGTH};
use super::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use lopi_memory::{ChainStepInput, ScheduleChainInput, ScheduleChainRow};
use lopi_orchestrator::next_run_times;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub(super) struct ChainStepBody {
pub goal: String,
#[serde(default)]
pub allowed_dirs: Option<Vec<String>>,
#[serde(default)]
pub forbidden_dirs: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ScheduleChainBody {
pub name: String,
pub cron: String,
pub steps: Vec<ChainStepBody>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub priority: Option<String>,
#[serde(default)]
pub autonomy_level: Option<String>,
#[serde(default)]
pub on_fail: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
}
impl ScheduleChainBody {
fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("name must not be empty".into());
}
if next_run_times(&self.cron, 1).is_empty() {
return Err("invalid cron expression (expected 5 fields)".into());
}
if self.steps.is_empty() {
return Err("chain must have at least one step".into());
}
for (i, step) in self.steps.iter().enumerate() {
if step.goal.trim().is_empty() {
return Err(format!("step {i} goal must not be empty"));
}
if step.goal.chars().count() > MAX_GOAL_LENGTH {
return Err(format!("step {i} goal exceeds {MAX_GOAL_LENGTH} chars"));
}
reject_control_chars(&step.goal).map_err(|e| format!("step {i}: {e}"))?;
}
Ok(())
}
fn into_input(self, id: Option<String>) -> ScheduleChainInput {
ScheduleChainInput {
id,
name: self.name,
cron: self.cron,
repo: self.repo,
priority: self.priority.unwrap_or_else(|| "normal".into()),
autonomy_level: self.autonomy_level.unwrap_or_default(),
on_fail: self.on_fail.unwrap_or_else(|| "stop".into()),
enabled: self.enabled.unwrap_or(true),
steps: self
.steps
.into_iter()
.map(|s| ChainStepInput {
goal: s.goal,
allowed_dirs: s.allowed_dirs.unwrap_or_default(),
forbidden_dirs: s.forbidden_dirs.unwrap_or_default(),
})
.collect(),
}
}
}
pub(super) async fn list_chains(State(s): State<AppState>) -> impl IntoResponse {
match s.store.list_schedule_chains().await {
Ok(rows) => {
let mut body = Vec::with_capacity(rows.len());
for row in rows {
body.push(chain_to_json(&s, row).await);
}
(StatusCode::OK, Json(json!({ "chains": body }))).into_response()
}
Err(e) => server_error(&e),
}
}
pub(super) async fn create_chain(
State(s): State<AppState>,
Json(body): Json<ScheduleChainBody>,
) -> impl IntoResponse {
if let Err(msg) = body.validate() {
return unprocessable(&msg);
}
persist_and_register(&s, body.into_input(None), StatusCode::CREATED).await
}
pub(super) async fn get_chain(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
match s.store.get_schedule_chain(&id).await {
Ok(Some(row)) => {
let runs = s.store.list_chain_runs(&id, 20).await.unwrap_or_default();
let mut value = chain_to_json(&s, row).await;
value["runs"] = json!(runs);
(StatusCode::OK, Json(value)).into_response()
}
Ok(None) => not_found(),
Err(e) => server_error(&e),
}
}
pub(super) async fn update_chain(
Path(id): Path<String>,
State(s): State<AppState>,
Json(body): Json<ScheduleChainBody>,
) -> impl IntoResponse {
if let Err(msg) = body.validate() {
return unprocessable(&msg);
}
match s.store.get_schedule_chain(&id).await {
Ok(Some(_)) => persist_and_register(&s, body.into_input(Some(id)), StatusCode::OK).await,
Ok(None) => not_found(),
Err(e) => server_error(&e),
}
}
pub(super) async fn delete_chain(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
match s.store.delete_schedule_chain(&id).await {
Ok(true) => {
if let Err(e) = s.schedule_chains.unregister(&id).await {
tracing::warn!("unregister chain after delete failed: {e:#}");
}
(StatusCode::OK, Json(json!({ "deleted": id }))).into_response()
}
Ok(false) => not_found(),
Err(e) => server_error(&e),
}
}
pub(super) async fn enable_chain(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
set_enabled(&s, &id, true).await
}
pub(super) async fn disable_chain(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
set_enabled(&s, &id, false).await
}
pub(super) async fn run_now(
Path(id): Path<String>,
State(s): State<AppState>,
) -> impl IntoResponse {
match s.store.get_schedule_chain(&id).await {
Ok(Some(row)) => match s.schedule_chains.run_now(row.into()).await {
Ok(run_id) => (
StatusCode::ACCEPTED,
Json(json!({ "chain_id": id, "run_id": run_id, "queued": run_id.is_some() })),
)
.into_response(),
Err(e) => server_error(&e),
},
Ok(None) => not_found(),
Err(e) => server_error(&e),
}
}
async fn persist_and_register(
s: &AppState,
input: ScheduleChainInput,
ok: StatusCode,
) -> axum::response::Response {
let row = match s.store.upsert_schedule_chain(&input).await {
Ok(r) => r,
Err(e) => return server_error(&e),
};
sync_job(s, &row).await;
let value = chain_to_json(s, row).await;
(ok, Json(value)).into_response()
}
async fn set_enabled(s: &AppState, id: &str, enabled: bool) -> axum::response::Response {
match s.store.set_schedule_chain_enabled(id, enabled).await {
Ok(true) => {
if let Ok(Some(row)) = s.store.get_schedule_chain(id).await {
sync_job(s, &row).await;
}
(
StatusCode::OK,
Json(json!({ "id": id, "enabled": enabled })),
)
.into_response()
}
Ok(false) => not_found(),
Err(e) => server_error(&e),
}
}
async fn sync_job(s: &AppState, row: &ScheduleChainRow) {
let result = if row.enabled {
s.schedule_chains
.register(row.clone().into())
.await
.map(|_| ())
} else {
s.schedule_chains.unregister(&row.id).await
};
if let Err(e) = result {
tracing::warn!(chain = %row.id, "chain scheduler sync failed: {e:#}");
}
}
async fn chain_to_json(s: &AppState, row: ScheduleChainRow) -> Value {
let next_runs: Vec<String> = next_run_times(&row.cron, 3)
.into_iter()
.map(|t| t.to_rfc3339())
.collect();
let last_run = s
.store
.list_chain_runs(&row.id, 1)
.await
.unwrap_or_default()
.into_iter()
.next();
json!({
"id": row.id, "name": row.name, "cron": row.cron,
"repo": row.repo, "priority": row.priority,
"autonomy_level": row.autonomy_level, "on_fail": row.on_fail,
"enabled": row.enabled, "steps": row.steps,
"created_at": row.created_at, "updated_at": row.updated_at,
"next_runs": next_runs, "last_run": last_run,
})
}
fn not_found() -> axum::response::Response {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "schedule chain not found" })),
)
.into_response()
}
fn unprocessable(msg: &str) -> axum::response::Response {
(
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": msg })),
)
.into_response()
}
fn server_error(e: &anyhow::Error) -> axum::response::Response {
tracing::warn!("schedule chain handler error: {e:#}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e:#}") })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_body() -> ScheduleChainBody {
ScheduleChainBody {
name: "nightly stack".to_string(),
cron: "0 2 * * *".to_string(),
steps: vec![ChainStepBody {
goal: "step one".to_string(),
allowed_dirs: None,
forbidden_dirs: None,
}],
repo: None,
priority: None,
autonomy_level: None,
on_fail: None,
enabled: None,
}
}
#[test]
fn validate_rejects_control_char_in_a_step_goal() {
let mut body = valid_body();
body.steps[0].goal = "step one\u{0007}".to_string();
assert!(body.validate().is_err());
}
#[test]
fn validate_accepts_a_well_formed_body() {
assert!(valid_body().validate().is_ok());
}
}