use crate::auth::can_read_run;
use crate::error::WorkflowApiError;
use crate::executor::{spawn_run, RunTask};
use crate::provenance::RunProvenance;
use crate::run_store::{ClaimOutcome, RunRecordView};
use crate::state::WorkflowRunState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Extension, Json,
};
use klieo_auth_common::Identity;
use klieo_flows::Flow;
use klieo_workflow::{canonical_json_bytes, compile, WorkflowDef};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::sync::OwnedSemaphorePermit;
const RUNNER_AGENT_NAME: &str = "workflow-run";
#[derive(Debug, Deserialize)]
pub(crate) struct SubmitBody {
pub definition: WorkflowDef,
#[serde(default)]
pub input: Value,
}
#[derive(Debug, Serialize)]
struct SubmitAccepted {
run_id: String,
}
pub(crate) async fn submit(
State(state): State<Arc<WorkflowRunState>>,
Extension(identity): Extension<Identity>,
Json(body): Json<SubmitBody>,
) -> Result<Response, WorkflowApiError> {
if !body.input.is_object() {
return Err(WorkflowApiError::BadRequest(
"input must be a JSON object".into(),
));
}
let flow = compile(&body.definition, &state.registry)?;
let permit = Arc::clone(&state.permits)
.try_acquire_owned()
.map_err(|_| WorkflowApiError::TooManyRuns)?;
let run_id = ulid::Ulid::new().to_string();
let key = idempotency_key(&body.definition, &body.input);
let provenance = run_provenance(&state, &run_id, &body.definition, identity.as_str());
if let Some(existing) = claim_or_existing(&state, &provenance, &key).await? {
return Ok(accepted(existing));
}
if let Err(e) = provenance.record_started().await {
roll_back_claim(&state, &key, &run_id).await;
return Err(WorkflowApiError::Internal(Box::new(e)));
}
spawn_task(&state, provenance, flow, body.input, permit);
Ok(accepted(run_id))
}
fn spawn_task(
state: &WorkflowRunState,
provenance: RunProvenance,
flow: Arc<dyn Flow>,
input: Value,
permit: OwnedSemaphorePermit,
) {
let task = RunTask {
flow,
ctx: state.app.context(RUNNER_AGENT_NAME),
input,
timeout: state.run_timeout,
permit,
progress_sender: state.progress.register(&provenance.run_id),
};
spawn_run(
state.run_store.clone(),
provenance,
state.progress.clone(),
task,
);
}
async fn roll_back_claim(state: &WorkflowRunState, idempotency_key: &str, run_id: &str) {
if let Err(e) = state
.run_store
.rollback_claim(idempotency_key, run_id)
.await
{
tracing::error!(run_id, error = %e, "failed to roll back claim after run-start audit failure");
}
}
fn run_provenance(
state: &WorkflowRunState,
run_id: &str,
definition: &WorkflowDef,
author: &str,
) -> RunProvenance {
RunProvenance {
repo: Arc::clone(&state.provenance),
run_id: run_id.to_string(),
author: author.to_string(),
workflow_id: definition.id.clone(),
content_hash: definition.canonical_hash(),
}
}
async fn claim_or_existing(
state: &WorkflowRunState,
provenance: &RunProvenance,
idempotency_key: &str,
) -> Result<Option<String>, WorkflowApiError> {
match state
.run_store
.claim_idempotent(idempotency_key, &provenance.run_id)
.await
{
Ok(ClaimOutcome::AlreadyExists(existing)) => Ok(Some(existing)),
Ok(ClaimOutcome::Created) => {
let view = RunRecordView::pending(
&provenance.run_id,
&provenance.workflow_id,
&provenance.author,
now_rfc3339(),
);
state
.run_store
.create(&view)
.await
.map_err(|e| WorkflowApiError::Internal(Box::new(e)))?;
Ok(None)
}
Err(e) => Err(WorkflowApiError::Internal(Box::new(e))),
}
}
pub(crate) async fn get_run(
State(state): State<Arc<WorkflowRunState>>,
Extension(identity): Extension<Identity>,
Path(run_id): Path<String>,
) -> Result<Json<RunRecordView>, WorkflowApiError> {
let view = match state.run_store.get(&run_id).await {
Ok(Some(view)) => view,
Ok(None) => return Err(WorkflowApiError::RunNotFound),
Err(e) => return Err(WorkflowApiError::Internal(Box::new(e))),
};
if !can_read_run(&identity, &view.author) {
return Err(WorkflowApiError::RunNotFound);
}
Ok(Json(view))
}
#[derive(Debug, Serialize)]
pub(crate) struct RegistryView {
models: Vec<String>,
tools: Vec<String>,
subflows: Vec<String>,
}
pub(crate) async fn health() -> &'static str {
"ok"
}
pub(crate) async fn get_registry(State(state): State<Arc<WorkflowRunState>>) -> Json<RegistryView> {
Json(RegistryView {
models: owned(state.registry.model_ids()),
tools: owned(state.registry.tool_ids()),
subflows: owned(state.registry.subflow_ids()),
})
}
fn owned(ids: Vec<&str>) -> Vec<String> {
ids.into_iter().map(String::from).collect()
}
fn accepted(run_id: String) -> Response {
(StatusCode::ACCEPTED, Json(SubmitAccepted { run_id })).into_response()
}
fn idempotency_key(definition: &WorkflowDef, input: &Value) -> String {
let mut hasher = Sha256::new();
hasher.update(definition.canonical_hash().as_bytes());
hasher.update([0u8]);
hasher.update(canonical_json_bytes(input));
hex::encode(hasher.finalize())
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}