use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::core::{
CaseId, Decision, Delivery, InboundEvent, PolicyDecision, PolicyRequest, RunId, Task, TaskId,
TenantId,
};
use crate::journal::RecordKind;
use crate::runtime::Runtime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Caller {
pub actor: String,
pub roles: Vec<String>,
pub tenant: TenantId,
}
impl Caller {
pub fn new(actor: impl Into<String>, roles: Vec<String>) -> Self {
Self {
actor: actor.into(),
roles,
tenant: TenantId::default(),
}
}
#[must_use]
pub fn in_tenant(mut self, tenant: TenantId) -> Self {
self.tenant = tenant;
self
}
}
#[async_trait::async_trait]
pub trait Authenticator: Send + Sync + std::fmt::Debug {
async fn authenticate(&self, headers: &HeaderMap) -> Result<Caller, AuthError>;
}
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("no credentials were presented")]
Missing,
#[error("the credentials presented were not accepted")]
Rejected,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiSetupError {
#[error(
"this runtime has no policy engine — an HTTP surface with no authorization \
layer grants every authenticated caller the whole plane; wire one (start \
with `DenyAll`) before opening a port"
)]
NoPolicy,
#[error(
"no planes were registered — this surface would authenticate callers \
and then refuse all of them; register at least one with `Planes::one`"
)]
NoPlanes,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CancelRequest {
pub reason: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DecisionRequest {
pub approved: bool,
pub reason: String,
#[serde(default)]
pub amendment: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RunView {
pub run: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub waiting_for: Option<String>,
pub sealed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cancellation_requested_by: Option<String>,
pub records: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub case: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TaskView {
pub id: String,
pub run: String,
pub kind: String,
pub justification: Value,
pub priority: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub case: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub decidable_by_you: bool,
}
impl TaskView {
fn of(task: Task, caller: &Caller) -> Self {
Self {
decidable_by_you: task.may_decide(&caller.actor, &caller.roles),
assignee: task.assignee,
id: task.id.to_hex(),
run: task.run.to_string(),
kind: task.kind,
justification: serde_json::to_value(&task.justification).unwrap_or(Value::Null),
priority: task.priority.as_str().to_owned(),
state: task.state.as_str().to_owned(),
case: task.case.map(|c| c.to_string()),
due_at: task.due_at.and_then(|d| {
d.format(&time::format_description::well_known::Rfc3339)
.ok()
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Worklist {
pub tasks: Vec<TaskView>,
pub truncated: bool,
}
#[cfg(feature = "a2a-server")]
pub mod a2a;
#[cfg(feature = "a2a-server")]
mod a2a_stream;
#[derive(Debug, Clone, Default)]
pub struct Planes {
by_tenant: std::collections::HashMap<TenantId, Arc<Runtime>>,
}
impl Planes {
#[must_use]
pub fn one(plane: Arc<Runtime>) -> Self {
Self::default().and(plane)
}
#[must_use]
pub fn and(mut self, plane: Arc<Runtime>) -> Self {
let tenant = plane.tenant().clone();
assert!(
self.by_tenant.insert(tenant.clone(), plane).is_none(),
"two planes are registered for tenant '{tenant}'. Whichever won, \
the other's skills, budgets and policy would be silently inert"
);
self
}
#[must_use]
pub fn get(&self, tenant: &TenantId) -> Option<&Arc<Runtime>> {
self.by_tenant.get(tenant)
}
pub fn tenants(&self) -> impl Iterator<Item = &TenantId> {
self.by_tenant.keys()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_tenant.is_empty()
}
}
impl From<Arc<Runtime>> for Planes {
fn from(plane: Arc<Runtime>) -> Self {
Self::one(plane)
}
}
struct Session {
caller: Caller,
plane: Arc<Runtime>,
}
#[derive(Clone)]
pub struct Api {
planes: Planes,
auth: Arc<dyn Authenticator>,
limit: usize,
}
impl std::fmt::Debug for Api {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Api")
.field("tenants", &self.planes.tenants().collect::<Vec<_>>())
.field("limit", &self.limit)
.finish_non_exhaustive()
}
}
impl Api {
pub const DEFAULT_LIMIT: usize = 100;
pub fn new(
planes: impl Into<Planes>,
auth: Arc<dyn Authenticator>,
) -> Result<Self, ApiSetupError> {
let planes = planes.into();
if planes.is_empty() {
return Err(ApiSetupError::NoPlanes);
}
for plane in planes.by_tenant.values() {
if plane.policy().is_none() {
return Err(ApiSetupError::NoPolicy);
}
}
Ok(Self {
planes,
auth,
limit: Self::DEFAULT_LIMIT,
})
}
#[must_use]
pub const fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
pub fn router(self) -> Router {
Router::new()
.route("/runs", get(runs_by_outcome))
.route("/runs/{run}", get(run_view))
.route("/runs/{run}/cancel", post(cancel_run))
.route("/tasks", get(worklist))
.route("/tasks/{task}", get(task_view))
.route("/tasks/{task}/claim", post(claim))
.route("/tasks/{task}/release", post(release))
.route("/tasks/{task}/decide", post(decide))
.route("/cases/{case}", get(case_view))
.route("/events", post(deliver))
.with_state(self)
}
async fn gate(
&self,
headers: &HeaderMap,
action: &str,
resource: &str,
) -> Result<Session, ApiError> {
let caller = self.auth.authenticate(headers).await?;
let plane = self.planes.get(&caller.tenant).ok_or_else(|| {
ApiError(
StatusCode::FORBIDDEN,
"this deployment serves no plane for your tenant".to_owned(),
)
})?;
let policy = plane.policy().ok_or_else(|| {
ApiError(StatusCode::FORBIDDEN, "this plane is ungoverned".to_owned())
})?;
let context = json!({ "roles": caller.roles, "tenant": caller.tenant.as_str() });
let decision = policy.authorize(&PolicyRequest {
principal: &caller.actor,
action,
resource,
context: &context,
});
match decision {
PolicyDecision::Permit => Ok(Session {
caller,
plane: Arc::clone(plane),
}),
PolicyDecision::Deny { reason } => Err(ApiError(StatusCode::FORBIDDEN, reason)),
}
}
}
pub mod action {
pub const RUN_READ: &str = "api:run.read";
pub const RUN_LIST: &str = "api:run.list";
pub const RUN_CANCEL: &str = "api:run.cancel";
pub const TASK_LIST: &str = "api:task.list";
pub const TASK_READ: &str = "api:task.read";
pub const TASK_CLAIM: &str = "api:task.claim";
pub const TASK_RELEASE: &str = "api:task.release";
pub const TASK_DECIDE: &str = "api:task.decide";
pub const CASE_READ: &str = "api:case.read";
pub const EVENT_DELIVER: &str = "api:event.deliver";
pub const ALL: &[&str] = &[
RUN_READ,
RUN_CANCEL,
TASK_LIST,
TASK_READ,
TASK_CLAIM,
TASK_RELEASE,
TASK_DECIDE,
CASE_READ,
EVENT_DELIVER,
];
}
struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({ "error": self.1 }))).into_response()
}
}
impl From<AuthError> for ApiError {
fn from(e: AuthError) -> Self {
Self(StatusCode::UNAUTHORIZED, e.to_string())
}
}
fn bad(what: &str) -> ApiError {
ApiError(StatusCode::BAD_REQUEST, format!("not a {what} id"))
}
fn unavailable(what: &str) -> ApiError {
ApiError(
StatusCode::NOT_IMPLEMENTED,
format!("this plane has no {what} store"),
)
}
fn store_failed() -> ApiError {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
"the store is unavailable".into(),
)
}
fn not_found(what: &str) -> ApiError {
ApiError(StatusCode::NOT_FOUND, format!("no such {what}"))
}
async fn run_view(
State(api): State<Api>,
headers: HeaderMap,
Path(run): Path<String>,
) -> Result<Json<RunView>, ApiError> {
let s = api.gate(&headers, action::RUN_READ, &run).await?;
let id = RunId::parse(&run).map_err(|_| bad("run"))?;
let records = s
.plane
.journal()
.read(id, 1)
.await
.map_err(|_| store_failed())?;
let Some(last) = records.last() else {
return Err(not_found("run"));
};
let (status, waiting_for, sealed) = match last.kind() {
RecordKind::RunSuspended { reason } => {
("suspended".to_owned(), Some(reason.to_string()), false)
}
RecordKind::RunSealed { outcome, .. } => (outcome.clone(), None, true),
_ => ("running".to_owned(), None, false),
};
let case = records
.iter()
.find_map(|r| r.body.case.map(|c| c.to_string()));
let cancellation_requested_by = s
.plane
.cancellation(id)
.await
.map_err(|_| store_failed())?
.map(|c| c.actor);
Ok(Json(RunView {
run: id.to_string(),
status,
waiting_for,
sealed,
cancellation_requested_by,
records: records.len() as u64,
case,
}))
}
async fn cancel_run(
State(api): State<Api>,
headers: HeaderMap,
Path(run): Path<String>,
Json(body): Json<CancelRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let s = api.gate(&headers, action::RUN_CANCEL, &run).await?;
let id = RunId::parse(&run).map_err(|_| bad("run"))?;
let fresh = s
.plane
.request_cancel(id, &s.caller.actor, &body.reason)
.await
.map_err(|e| ApiError(StatusCode::CONFLICT, e.to_string()))?;
Ok((
StatusCode::ACCEPTED,
Json(json!({
"requested_by": s.caller.actor,
"recorded": fresh,
})),
))
}
async fn runs_by_outcome(
State(api): State<Api>,
headers: HeaderMap,
Query(q): Query<OutcomeQuery>,
) -> Result<Json<Value>, ApiError> {
let outcome = q.outcome.unwrap_or_else(|| "quarantined".to_owned());
let s = api.gate(&headers, action::RUN_LIST, &outcome).await?;
let mut found = s
.plane
.journal()
.runs_by_outcome(&outcome, api.limit + 1)
.await
.map_err(|_| store_failed())?;
let truncated = found.len() > api.limit;
found.truncate(api.limit);
Ok(Json(json!({
"outcome": outcome,
"runs": found.iter().map(ToString::to_string).collect::<Vec<_>>(),
"truncated": truncated,
})))
}
#[derive(serde::Deserialize)]
struct OutcomeQuery {
outcome: Option<String>,
}
async fn worklist(State(api): State<Api>, headers: HeaderMap) -> Result<Json<Worklist>, ApiError> {
let s = api.gate(&headers, action::TASK_LIST, "*").await?;
let tasks = s.plane.tasks().ok_or_else(|| unavailable("task"))?;
let mut queued = tasks
.queue(&s.caller.roles, api.limit + 1)
.await
.map_err(|_| store_failed())?;
let truncated = queued.len() > api.limit;
queued.truncate(api.limit);
Ok(Json(Worklist {
tasks: queued
.into_iter()
.map(|t| TaskView::of(t, &s.caller))
.collect(),
truncated,
}))
}
async fn task_view(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<Json<TaskView>, ApiError> {
let s = api.gate(&headers, action::TASK_READ, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = s.plane.tasks().ok_or_else(|| unavailable("task"))?;
let found = tasks
.task(id)
.await
.map_err(|_| store_failed())?
.ok_or_else(|| not_found("task"))?;
Ok(Json(TaskView::of(found, &s.caller)))
}
async fn claim(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<Json<TaskView>, ApiError> {
let s = api.gate(&headers, action::TASK_CLAIM, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = s.plane.tasks().ok_or_else(|| unavailable("task"))?;
let claimed = tasks
.claim(id, &s.caller.actor, &s.caller.roles)
.await
.map_err(|e| claim_refused(&e))?;
Ok(Json(TaskView::of(claimed, &s.caller)))
}
async fn release(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
) -> Result<StatusCode, ApiError> {
let s = api.gate(&headers, action::TASK_RELEASE, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let tasks = s.plane.tasks().ok_or_else(|| unavailable("task"))?;
tasks
.release(id, &s.caller.actor)
.await
.map_err(|e| claim_refused(&e))?;
Ok(StatusCode::NO_CONTENT)
}
fn claim_refused(e: &crate::case::ClaimError) -> ApiError {
use crate::case::ClaimError;
let status = match *e {
ClaimError::Excluded { .. } | ClaimError::WrongRole { .. } => StatusCode::FORBIDDEN,
ClaimError::NotFound(_) => StatusCode::NOT_FOUND,
ClaimError::AlreadyClaimed { .. }
| ClaimError::NotPending { .. }
| ClaimError::NotHeld { .. } => StatusCode::CONFLICT,
ClaimError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let detail = if status == StatusCode::INTERNAL_SERVER_ERROR {
"the store is unavailable".to_owned()
} else {
e.to_string()
};
ApiError(status, detail)
}
async fn decide(
State(api): State<Api>,
headers: HeaderMap,
Path(task): Path<String>,
Json(body): Json<DecisionRequest>,
) -> Result<Json<Value>, ApiError> {
let s = api.gate(&headers, action::TASK_DECIDE, &task).await?;
let id = TaskId::parse(&task).map_err(|_| bad("task"))?;
let decision = Decision {
approved: body.approved,
actor: s.caller.actor.clone(),
reason: body.reason,
amendment: body.amendment,
};
s.plane
.decide_task(id, &decision, &s.caller.roles)
.await
.map_err(|e| match e {
crate::core::RuntimeError::PolicyDenied(_) => {
ApiError(StatusCode::FORBIDDEN, e.to_string())
}
other => ApiError(StatusCode::CONFLICT, other.to_string()),
})?;
Ok(Json(json!({
"decided_by": s.caller.actor,
"approved": decision.approved,
})))
}
const CASE_HISTORY_LIMIT: usize = 200;
async fn case_view(
State(api): State<Api>,
headers: HeaderMap,
Path(case): Path<String>,
) -> Result<Json<Value>, ApiError> {
let s = api.gate(&headers, action::CASE_READ, &case).await?;
let id = CaseId::parse(&case).map_err(|_| bad("case"))?;
let cases = s.plane.cases().ok_or_else(|| unavailable("case"))?;
let found = cases
.case(id)
.await
.map_err(|_| store_failed())?
.ok_or_else(|| not_found("case"))?;
let deadlines = cases.deadlines(id).await.map_err(|_| store_failed())?;
let history = s
.plane
.journal()
.case_history(id, CASE_HISTORY_LIMIT)
.await
.map_err(|_| store_failed())?;
Ok(Json(json!({
"case": serde_json::to_value(&found).unwrap_or(Value::Null),
"deadlines": serde_json::to_value(&deadlines).unwrap_or(Value::Null),
"history": history
.iter()
.map(|r| json!({
"seq": r.seq(),
"run": r.body.run.to_string(),
"kind": r.kind().kind_str(),
"record": serde_json::to_value(r.kind()).unwrap_or(Value::Null),
}))
.collect::<Vec<_>>(),
"history_truncated": history.len() >= CASE_HISTORY_LIMIT,
})))
}
#[derive(serde::Deserialize)]
struct DeliverBody {
id: String,
kind: String,
#[serde(default)]
correlation: Vec<crate::core::CorrelationKey>,
payload: Value,
}
async fn deliver(
State(api): State<Api>,
headers: HeaderMap,
Json(body): Json<DeliverBody>,
) -> Result<Json<Value>, ApiError> {
let s = api
.gate(&headers, action::EVENT_DELIVER, &body.kind)
.await?;
let mut event = InboundEvent::new(s.caller.actor.clone(), body.id, body.kind, body.payload);
event.correlation = body.correlation;
let delivery = s
.plane
.deliver(&event)
.await
.map_err(|e| ApiError(StatusCode::CONFLICT, e.to_string()))?;
Ok(Json(match delivery {
Delivery::Resumed { run } => json!({ "delivery": "resumed", "run": run.to_string() }),
Delivery::Buffered => json!({ "delivery": "buffered" }),
Delivery::Duplicate => json!({ "delivery": "duplicate" }),
}))
}