use axum::Json;
use axum::extract::{Path, State};
use axum::{Extension, http::StatusCode};
use serde_json::Value;
use crate::errors::OrionError;
use crate::server::admin_auth::AdminPrincipal;
use crate::server::extract::OrionQuery;
use crate::server::routes::openapi::{DataEnvelope, PaginatedEnvelope};
use crate::server::routes::response_helpers::{data_response, paginated_response};
use crate::server::state::AppState;
use crate::storage::models::{
CronOccurrenceResponse, CronOccurrenceSummaryResponse, CronScheduleStatusResponse,
};
use crate::storage::repositories::cron::{CronOccurrenceFilter, status, trigger};
use super::audit_log;
#[utoipa::path(
get,
path = "/api/v1/admin/cron/occurrences",
tag = "Cron",
params(CronOccurrenceFilter),
responses(
(status = 200, description = "Paginated occurrences, newest first. Summaries only — \
fetch one by id for the failure reason, the trace id and the lease detail", body = PaginatedEnvelope<CronOccurrenceSummaryResponse>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn list_occurrences(
State(state): State<AppState>,
OrionQuery(filter): OrionQuery<CronOccurrenceFilter>,
) -> Result<Json<Value>, OrionError> {
let result = state.repos.cron.list_paginated(&filter).await?;
let rows: Vec<CronOccurrenceSummaryResponse> = result
.data
.iter()
.map(CronOccurrenceSummaryResponse::from)
.collect();
Ok(paginated_response(
rows,
result.total,
result.limit,
result.offset,
))
}
#[utoipa::path(
get,
path = "/api/v1/admin/cron/occurrences/{id}",
tag = "Cron",
params(("id" = String, Path, description = "Occurrence id")),
responses(
(status = 200, description = "One occurrence in full", body = DataEnvelope<CronOccurrenceResponse>),
(status = 404, description = "No such occurrence", body = crate::server::routes::openapi::ErrorResponse),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn get_occurrence(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Value>, OrionError> {
let occurrence = state.repos.cron.get_by_id(&id).await?;
Ok(data_response(CronOccurrenceResponse::from(&occurrence)))
}
#[utoipa::path(
post,
path = "/api/v1/admin/cron/occurrences/{id}/retry",
tag = "Cron",
params(("id" = String, Path, description = "Occurrence id")),
responses(
(status = 200, description = "Occurrence reset to `pending`; the next worker poll picks it up", body = DataEnvelope<CronOccurrenceResponse>),
(status = 404, description = "No such occurrence", body = crate::server::routes::openapi::ErrorResponse),
(status = 409, description = "The occurrence is not in a retryable state — \
`completed` work is re-run by triggering the channel, not by retrying a finished occurrence", body = crate::server::routes::openapi::ErrorResponse),
)
)]
#[tracing::instrument(skip(state, principal))]
pub(crate) async fn retry_occurrence(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
) -> Result<Json<Value>, OrionError> {
let occurrence = state.repos.cron.requeue(&id).await?;
audit_log(
&state.audit_queue,
&principal,
"retry",
"cron_occurrence",
&id,
);
Ok(data_response(CronOccurrenceResponse::from(&occurrence)))
}
#[utoipa::path(
get,
path = "/api/v1/admin/cron/status",
tag = "Cron",
responses(
(status = 200, description = "One row per active cron channel: its schedule, where its \
cursor has got to, its most recent occurrence and its backlog", body = DataEnvelope<Vec<CronScheduleStatusResponse>>),
)
)]
#[tracing::instrument(skip(state))]
pub(crate) async fn cron_status(State(state): State<AppState>) -> Result<Json<Value>, OrionError> {
let generation = state.runtime.load();
let descriptors = generation.channels.cron_descriptors();
let cursors = state.repos.cron.schedule_states().await?;
let mut rows = Vec::with_capacity(descriptors.len());
for descriptor in descriptors {
let cursor = cursors
.iter()
.find(|c| c.channel_id == descriptor.channel_id);
let latest = state
.repos
.cron
.latest_for_channel(&descriptor.channel_id)
.await?;
rows.push(CronScheduleStatusResponse {
channel_id: descriptor.channel_id.clone(),
channel_name: descriptor.channel_name.clone(),
schedule: descriptor.expression.clone(),
timezone: descriptor.timezone.name().to_string(),
next_fire_at: cursor.map(|c| c.next_fire_at),
paused_at: cursor.and_then(|c| c.paused_at),
last_status: latest.as_ref().map(|o| o.status.clone()),
last_scheduled_for: latest.as_ref().map(|o| o.scheduled_for),
last_completed_at: latest.as_ref().and_then(|o| o.completed_at),
pending: state
.repos
.cron
.pending_count(Some(&descriptor.channel_id))
.await?,
});
}
Ok(data_response(rows))
}
#[utoipa::path(
post,
path = "/api/v1/admin/channels/{id}/trigger",
tag = "Cron",
params(("id" = String, Path, description = "Channel id of an active cron channel")),
responses(
(status = 202, description = "An occurrence was created; a worker picks it up on its next \
poll. It runs through the same claim, singleton and execution path a scheduled \
occurrence does", body = DataEnvelope<CronOccurrenceResponse>),
(status = 400, description = "Not an active cron channel", body = crate::server::routes::openapi::ErrorResponse),
(status = 409, description = "An occurrence already exists for this instant — retry in a second", body = crate::server::routes::openapi::ErrorResponse),
)
)]
#[tracing::instrument(skip(state, principal))]
pub(crate) async fn trigger_channel(
State(state): State<AppState>,
principal: Option<Extension<AdminPrincipal>>,
Path(id): Path<String>,
) -> Result<(StatusCode, Json<Value>), OrionError> {
let generation = state.runtime.load();
let Some(runtime) = generation.channels.cron_by_channel_id(&id) else {
return Err(OrionError::validation(format!(
"Channel '{id}' is not an active cron channel on this node, so there is no \
schedule to trigger. Only a `protocol: \"cron\"` channel that is active and \
loaded can be triggered."
)));
};
let descriptor = runtime
.cron
.as_ref()
.expect("cron_by_channel_id only returns cron channels");
let scheduled_for = state.repos.cron.db_now().await?;
let occurrence_id = uuid::Uuid::now_v7().to_string();
let created = state
.repos
.cron
.insert_occurrence(crate::storage::repositories::cron::NewOccurrence {
id: &occurrence_id,
channel_id: &descriptor.channel_id,
channel_name: &descriptor.channel_name,
channel_version: runtime.channel.version,
workflow_id: descriptor.workflow_id.as_deref(),
trigger: trigger::MANUAL,
scheduled_for,
status: status::PENDING,
error_message: None,
})
.await?;
if !created {
return Err(OrionError::Conflict(format!(
"An occurrence for channel '{id}' already exists at {scheduled_for} — \
a scheduled run claimed this instant. Try again in a second."
)));
}
crate::metrics::record_cron_occurrence(status::PENDING);
audit_log(&state.audit_queue, &principal, "trigger", "channel", &id);
let occurrence = state.repos.cron.get_by_id(&occurrence_id).await?;
Ok((
StatusCode::ACCEPTED,
data_response(CronOccurrenceResponse::from(&occurrence)),
))
}