Skip to main content

orbit_runtime/
server.rs

1//! HTTP API (RFC-0001 §11.2) and the HTTP/JSON binding of the worker
2//! protocol (§8). See `protocol/README.md` for the wire format.
3
4use crate::engine::{Engine, EngineError, RegisterWorker, TaskAssignment};
5use crate::identity::{Identities, WorkerIdentity};
6use crate::model::TaskError;
7use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
8use axum::http::{HeaderMap, HeaderValue, StatusCode};
9use axum::middleware::{self, Next};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum::{Extension, Json, Router};
13use serde::Deserialize;
14use serde_json::{json, Value};
15use std::net::SocketAddr;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18use tokio::sync::Notify;
19
20pub const API_VERSION: &str = "orbit-api/1";
21
22pub struct AppState {
23    pub engine: Mutex<Engine>,
24    pub changed: Arc<Notify>,
25    pub auth_token: Option<String>,
26    /// Optional credential restricted to the worker protocol.
27    pub worker_token: Option<String>,
28    /// Per-worker identities (`--workers-file`).
29    pub identities: Identities,
30}
31
32pub type Shared = Arc<AppState>;
33
34pub struct ApiError(pub(crate) EngineError);
35
36impl From<EngineError> for ApiError {
37    fn from(e: EngineError) -> Self {
38        ApiError(e)
39    }
40}
41
42impl IntoResponse for ApiError {
43    fn into_response(self) -> Response {
44        let status =
45            StatusCode::from_u16(self.0.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
46        (
47            status,
48            Json(json!({ "error": { "code": self.0.code, "message": self.0.message } })),
49        )
50            .into_response()
51    }
52}
53
54fn bad_request(msg: impl Into<String>) -> ApiError {
55    ApiError(EngineError::new(400, "INVALID_REQUEST", msg))
56}
57
58type ApiResult<T> = Result<T, ApiError>;
59
60pub(crate) fn engine(state: &Shared) -> std::sync::MutexGuard<'_, Engine> {
61    state.engine.lock().unwrap_or_else(|p| p.into_inner())
62}
63
64pub fn router(state: Shared, body_limit: usize) -> Router {
65    Router::new()
66        .route("/v1/health", get(health))
67        .route("/v1/metrics", get(metrics))
68        .route("/v1/workflows", post(register_workflow).get(list_workflows))
69        .route("/v1/workflows/{name}", get(get_workflow_latest))
70        .route("/v1/workflows/{name}/{version}", get(get_workflow))
71        .route("/v1/executions", post(start_execution).get(list_executions))
72        .route("/v1/executions/{id}", get(get_execution))
73        .route("/v1/executions/{id}/history", get(get_history))
74        .route("/v1/executions/{id}/cancel", post(cancel_execution))
75        .route("/v1/executions/{id}/signals/{name}", post(send_signal))
76        .route("/v1/executions/{id}/queries/{name}", get(run_query))
77        .route("/v1/executions/{id}/wait", get(wait_execution))
78        .route("/v1/workers", get(list_workers))
79        .route("/v1/workers/register", post(register_worker))
80        .route("/v1/workers/heartbeat", post(heartbeat))
81        .route("/v1/workers/poll", post(poll))
82        .route("/v1/workers/unregister", post(unregister))
83        .route("/v1/tasks/complete", post(complete_task))
84        .route("/v1/tasks/fail", post(fail_task))
85        .layer(middleware::from_fn_with_state(state.clone(), auth))
86        .layer(DefaultBodyLimit::max(body_limit))
87        .with_state(state)
88}
89
90/// Endpoints a worker credential may call.
91fn is_worker_path(path: &str) -> bool {
92    path.starts_with("/v1/workers/") || path.starts_with("/v1/tasks/")
93}
94
95/// Who is calling, as established by [`auth`].
96#[derive(Clone)]
97pub enum Caller {
98    /// No authentication is configured (loopback development).
99    Open,
100    Admin,
101    /// The shared, restricted worker token (`--worker-token`).
102    SharedWorker,
103    /// A worker from the workers file, by token or client certificate.
104    Worker(Arc<WorkerIdentity>),
105    /// An application from the workers file (`[[client]]`): the execution
106    /// API for the workflows and actions it was granted.
107    Client(Arc<crate::identity::ClientIdentity>),
108}
109
110impl Caller {
111    fn identity(&self) -> Option<&WorkerIdentity> {
112        match self {
113            Caller::Worker(w) => Some(w),
114            _ => None,
115        }
116    }
117}
118
119fn forbidden(msg: impl Into<String>) -> ApiError {
120    ApiError(EngineError::new(403, "FORBIDDEN", msg))
121}
122
123/// A client identity may only act on the workflows and actions it was granted.
124fn allow(caller: &Caller, action: &str, workflow: &str) -> ApiResult<()> {
125    if let Caller::Client(c) = caller {
126        if !c.allows(action, workflow) {
127            return Err(forbidden(format!(
128                "client `{}` may not {action} executions of `{workflow}`",
129                c.id
130            )));
131        }
132    }
133    Ok(())
134}
135
136/// A client may read a workflow definition only if it may read its executions.
137fn allow_definition(caller: &Caller, workflow: &str) -> ApiResult<()> {
138    if let Caller::Client(c) = caller {
139        if !c.can_read(workflow) {
140            return Err(forbidden(format!(
141                "client `{}` may not read the definition of `{workflow}`",
142                c.id
143            )));
144        }
145    }
146    Ok(())
147}
148
149/// `allow` for an existing execution, looked up by id.
150fn allow_execution(state: &Shared, caller: &Caller, action: &str, id: &str) -> ApiResult<()> {
151    if matches!(caller, Caller::Client(_)) {
152        let workflow = engine(state).execution(id)?.workflow;
153        allow(caller, action, &workflow)?;
154    }
155    Ok(())
156}
157
158/// Authentication.
159///
160/// When any credential is configured, every endpoint except `/v1/health`
161/// requires one. The admin token can call anything. Worker credentials (the
162/// shared `--worker-token`, a per-worker token from the workers file, or a
163/// client certificate under mutual TLS) can only call the worker protocol
164/// endpoints, so a compromised worker host cannot start, cancel or read
165/// executions. Per-worker identities are further limited by the handlers to
166/// their own id, tasks, sessions and task tokens.
167/// Establish the caller from a bearer token and/or a verified client
168/// certificate. Shared by the HTTP API and the gRPC binding. `Ok(None)` means
169/// no credential was presented while authentication is required.
170pub(crate) fn identify(
171    state: &AppState,
172    bearer: Option<&str>,
173    cert_der: Option<&[u8]>,
174) -> Result<Option<Caller>, ApiError> {
175    let secured = state.auth_token.is_some() || !state.identities.is_empty();
176    if !secured {
177        return Ok(Some(Caller::Open));
178    }
179    if let Some(t) = bearer {
180        if state
181            .auth_token
182            .as_ref()
183            .is_some_and(|a| constant_time_eq(t.as_bytes(), a.as_bytes()))
184        {
185            return Ok(Some(Caller::Admin));
186        }
187        if let Some(w) = state.identities.by_token(t) {
188            return Ok(Some(Caller::Worker(Arc::new(w.clone()))));
189        }
190        if let Some(c) = state.identities.client_by_token(t) {
191            return Ok(Some(Caller::Client(Arc::new(c.clone()))));
192        }
193        if state
194            .worker_token
195            .as_ref()
196            .is_some_and(|w| constant_time_eq(t.as_bytes(), w.as_bytes()))
197        {
198            return Ok(Some(Caller::SharedWorker));
199        }
200    }
201    if let Some(der) = cert_der {
202        if let Some(w) = state.identities.by_certificate(der) {
203            return Ok(Some(Caller::Worker(Arc::new(w.clone()))));
204        }
205        return match state.identities.client_by_certificate(der) {
206            Some(c) => Ok(Some(Caller::Client(Arc::new(c.clone())))),
207            None => Err(forbidden(
208                "the client certificate is valid but matches no worker or client in the workers file",
209            )),
210        };
211    }
212    Ok(None)
213}
214
215async fn auth(State(state): State<Shared>, mut req: Request, next: Next) -> Response {
216    let path = req.uri().path().to_string();
217    let bearer = req
218        .headers()
219        .get("authorization")
220        .and_then(|v| v.to_str().ok())
221        .and_then(|v| v.strip_prefix("Bearer "))
222        .map(str::to_string);
223    let cert = req
224        .extensions()
225        .get::<Arc<axum_server::tls::TlsInfo>>()
226        .and_then(|info| info.peer_certificates.first().cloned());
227    let caller = match identify(&state, bearer.as_deref(), cert.as_deref()) {
228        Ok(Some(c)) => c,
229        Ok(None) if path == "/v1/health" => Caller::Open,
230        Ok(None) => {
231            return ApiError(EngineError::new(
232                401,
233                "UNAUTHENTICATED",
234                "missing or invalid credentials",
235            ))
236            .into_response()
237        }
238        Err(e) => return e.into_response(),
239    };
240    if matches!(caller, Caller::SharedWorker | Caller::Worker(_))
241        && !is_worker_path(&path)
242        && path != "/v1/health"
243    {
244        return forbidden("worker credentials may only call /v1/workers/* and /v1/tasks/*")
245            .into_response();
246    }
247    if let Caller::Client(c) = &caller {
248        let admin_only = (path == "/v1/workflows" && req.method() == axum::http::Method::POST)
249            || path.starts_with("/v1/workers")
250            || path == "/v1/metrics";
251        if is_worker_path(&path) || admin_only {
252            return forbidden(format!(
253                "client `{}` may only use the execution API (start, signal, query, read, cancel)",
254                c.id
255            ))
256            .into_response();
257        }
258    }
259    req.extensions_mut().insert(caller);
260    let mut resp = next.run(req).await;
261    resp.headers_mut()
262        .insert("x-orbit-api-version", HeaderValue::from_static(API_VERSION));
263    resp
264}
265
266/// A per-worker identity may only use sessions it opened.
267pub(crate) fn authorize_session(state: &Shared, caller: &Caller, session: &str) -> ApiResult<()> {
268    if let Some(id) = caller.identity() {
269        if let Some(owner) = engine(state).session_worker_lookup(session) {
270            if owner != id.id {
271                return Err(forbidden(format!(
272                    "session belongs to worker `{owner}`, not `{}`",
273                    id.id
274                )));
275            }
276        }
277    }
278    Ok(())
279}
280
281/// A per-worker identity may only report results for tokens issued to it.
282pub(crate) fn authorize_token(state: &Shared, caller: &Caller, token: &str) -> ApiResult<()> {
283    if let Some(id) = caller.identity() {
284        if let Some(owner) = engine(state).token_worker_lookup(token) {
285            if owner != id.id {
286                return Err(forbidden(format!(
287                    "task token was issued to worker `{owner}`, not `{}`",
288                    id.id
289                )));
290            }
291        }
292    }
293    Ok(())
294}
295
296fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
297    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
298}
299
300async fn health(State(state): State<Shared>) -> Json<Value> {
301    let e = engine(&state);
302    Json(json!({
303        "status": "ok",
304        "api_version": API_VERSION,
305        "runtime_version": env!("CARGO_PKG_VERSION"),
306        "ir_versions": orbit_ir::SUPPORTED_IR_VERSIONS,
307        "active_executions": e.active_count(),
308        "worker_sessions": e.session_count(),
309    }))
310}
311
312async fn metrics(State(state): State<Shared>) -> Response {
313    let e = engine(&state);
314    let body = e.metrics.render(e.active_count(), e.session_count());
315    let mut headers = HeaderMap::new();
316    headers.insert(
317        "content-type",
318        HeaderValue::from_static("text/plain; version=0.0.4"),
319    );
320    (headers, body).into_response()
321}
322
323async fn register_workflow(State(state): State<Shared>, body: String) -> ApiResult<Response> {
324    let ir = orbit_ir::WorkflowIr::from_json(&body)
325        .map_err(|e| ApiError(EngineError::new(400, "INVALID_IR", e.0)))?;
326    let (name, version, hash) = (
327        ir.name.clone(),
328        ir.version.clone(),
329        ir.definition_hash.clone(),
330    );
331    let created = engine(&state).register_definition(ir)?;
332    let status = if created {
333        StatusCode::CREATED
334    } else {
335        StatusCode::OK
336    };
337    Ok((status, Json(json!({ "name": name, "version": version, "definition_hash": hash, "created": created }))).into_response())
338}
339
340fn summary(d: &orbit_ir::WorkflowIr) -> Value {
341    json!({
342        "name": d.name,
343        "version": d.version,
344        "description": d.description,
345        "definition_hash": d.definition_hash,
346        "steps": d.steps.iter().map(|s| s.id.clone()).collect::<Vec<_>>(),
347        "tasks": d.contracts.keys().collect::<Vec<_>>(),
348    })
349}
350
351async fn list_workflows(
352    State(state): State<Shared>,
353    Extension(caller): Extension<Caller>,
354) -> Json<Value> {
355    let defs = engine(&state).definitions();
356    let visible = |name: &str| match &caller {
357        Caller::Client(c) => c.can_read(name),
358        _ => true,
359    };
360    Json(
361        json!({ "workflows": defs.iter().filter(|d| visible(&d.name)).map(|d| summary(d)).collect::<Vec<_>>() }),
362    )
363}
364
365async fn get_workflow(
366    State(state): State<Shared>,
367    Extension(caller): Extension<Caller>,
368    Path((name, version)): Path<(String, String)>,
369) -> ApiResult<Json<Value>> {
370    allow_definition(&caller, &name)?;
371    let d = engine(&state)
372        .definition(&name, Some(&version))
373        .ok_or_else(|| {
374            ApiError(EngineError::new(
375                404,
376                "WORKFLOW_NOT_FOUND",
377                "workflow not found",
378            ))
379        })?;
380    Ok(Json(serde_json::to_value(&*d).unwrap_or_default()))
381}
382
383async fn get_workflow_latest(
384    State(state): State<Shared>,
385    Extension(caller): Extension<Caller>,
386    Path(name): Path<String>,
387) -> ApiResult<Json<Value>> {
388    allow_definition(&caller, &name)?;
389    let d = engine(&state).definition(&name, None).ok_or_else(|| {
390        ApiError(EngineError::new(
391            404,
392            "WORKFLOW_NOT_FOUND",
393            "workflow not found",
394        ))
395    })?;
396    Ok(Json(serde_json::to_value(&*d).unwrap_or_default()))
397}
398
399#[derive(Deserialize)]
400struct StartRequest {
401    workflow: String,
402    #[serde(default)]
403    version: Option<String>,
404    #[serde(default)]
405    input: Value,
406    #[serde(default)]
407    execution_id: Option<String>,
408}
409
410async fn start_execution(
411    State(state): State<Shared>,
412    Extension(caller): Extension<Caller>,
413    Json(req): Json<StartRequest>,
414) -> ApiResult<Response> {
415    allow(&caller, "start", &req.workflow)?;
416    let r = engine(&state).start(
417        &req.workflow,
418        req.version.as_deref(),
419        req.input,
420        req.execution_id,
421    )?;
422    let status = if r.created {
423        StatusCode::CREATED
424    } else {
425        StatusCode::OK
426    };
427    Ok((status, Json(serde_json::to_value(r).unwrap_or_default())).into_response())
428}
429
430#[derive(Deserialize)]
431struct ListQuery {
432    status: Option<String>,
433    workflow: Option<String>,
434    limit: Option<u32>,
435}
436
437async fn list_executions(
438    State(state): State<Shared>,
439    Extension(caller): Extension<Caller>,
440    Query(q): Query<ListQuery>,
441) -> ApiResult<Json<Value>> {
442    let limit = q.limit.unwrap_or(50).clamp(1, 1000);
443    // A client sees only the workflows it may read: look further back so the
444    // page still holds `limit` executions it is allowed to see.
445    let fetch = if matches!(caller, Caller::Client(_)) {
446        1000
447    } else {
448        limit
449    };
450    let mut list = engine(&state).list_executions(
451        q.status.as_deref().map(str::to_uppercase).as_deref(),
452        q.workflow.as_deref(),
453        fetch,
454    )?;
455    if let Caller::Client(c) = &caller {
456        list.retain(|e| c.can_read(&e.workflow));
457        list.truncate(limit as usize);
458    }
459    Ok(Json(json!({ "executions": list })))
460}
461
462async fn get_execution(
463    State(state): State<Shared>,
464    Extension(caller): Extension<Caller>,
465    Path(id): Path<String>,
466) -> ApiResult<Json<Value>> {
467    allow_execution(&state, &caller, "read", &id)?;
468    let s = engine(&state).execution(&id)?;
469    Ok(Json(serde_json::to_value(s).unwrap_or_default()))
470}
471
472async fn get_history(
473    State(state): State<Shared>,
474    Extension(caller): Extension<Caller>,
475    Path(id): Path<String>,
476) -> ApiResult<Json<Value>> {
477    allow_execution(&state, &caller, "read", &id)?;
478    let h = engine(&state).history(&id)?;
479    Ok(Json(json!({ "execution_id": id, "events": h })))
480}
481
482#[derive(Deserialize, Default)]
483struct CancelRequest {
484    #[serde(default)]
485    reason: Option<String>,
486}
487
488async fn cancel_execution(
489    State(state): State<Shared>,
490    Extension(caller): Extension<Caller>,
491    Path(id): Path<String>,
492    body: Option<Json<CancelRequest>>,
493) -> ApiResult<Json<Value>> {
494    allow_execution(&state, &caller, "cancel", &id)?;
495    let reason = body
496        .and_then(|b| b.0.reason)
497        .unwrap_or_else(|| "requested via API".into());
498    let s = engine(&state).cancel(&id, &reason)?;
499    Ok(Json(serde_json::to_value(s).unwrap_or_default()))
500}
501
502/// `POST /v1/executions/{id}/signals/{name}` with the payload as the body
503/// (an empty body sends `{}`).
504async fn send_signal(
505    State(state): State<Shared>,
506    Extension(caller): Extension<Caller>,
507    Path((id, name)): Path<(String, String)>,
508    body: Option<Json<Value>>,
509) -> ApiResult<Json<Value>> {
510    allow_execution(&state, &caller, "signal", &id)?;
511    let payload = body.map(|b| b.0).unwrap_or_else(|| json!({}));
512    let seq = engine(&state).signal(&id, &name, payload)?;
513    Ok(Json(json!({
514        "execution_id": id,
515        "signal": name,
516        "accepted": true,
517        "seq": seq,
518    })))
519}
520
521/// `GET /v1/executions/{id}/queries/{name}`: evaluate a declared query.
522async fn run_query(
523    State(state): State<Shared>,
524    Extension(caller): Extension<Caller>,
525    Path((id, name)): Path<(String, String)>,
526) -> ApiResult<Json<Value>> {
527    allow_execution(&state, &caller, "query", &id)?;
528    let value = engine(&state).query(&id, &name)?;
529    Ok(Json(
530        json!({ "execution_id": id, "query": name, "value": value }),
531    ))
532}
533
534#[derive(Deserialize)]
535struct WaitQuery {
536    timeout_ms: Option<u64>,
537}
538
539/// Long-poll until the execution reaches a terminal status (or the timeout).
540async fn wait_execution(
541    State(state): State<Shared>,
542    Extension(caller): Extension<Caller>,
543    Path(id): Path<String>,
544    Query(q): Query<WaitQuery>,
545) -> ApiResult<Json<Value>> {
546    allow_execution(&state, &caller, "read", &id)?;
547    let deadline = tokio::time::Instant::now()
548        + Duration::from_millis(q.timeout_ms.unwrap_or(30_000).min(300_000));
549    loop {
550        let notified = state.changed.notified();
551        tokio::pin!(notified);
552        notified.as_mut().enable();
553        let s = engine(&state).execution(&id)?;
554        if s.status.is_terminal() || tokio::time::Instant::now() >= deadline {
555            return Ok(Json(serde_json::to_value(s).unwrap_or_default()));
556        }
557        let _ = tokio::time::timeout_at(deadline, notified).await;
558    }
559}
560
561async fn list_workers(State(state): State<Shared>) -> ApiResult<Json<Value>> {
562    Ok(Json(json!({ "workers": engine(&state).workers()? })))
563}
564
565async fn register_worker(
566    State(state): State<Shared>,
567    Extension(caller): Extension<Caller>,
568    Json(req): Json<RegisterWorker>,
569) -> ApiResult<Json<Value>> {
570    if let Some(id) = caller.identity() {
571        if req.worker_id != id.id {
572            return Err(forbidden(format!(
573                "this credential belongs to worker `{}`, not `{}`",
574                id.id, req.worker_id
575            )));
576        }
577        if let Some(rt) = &id.runtime {
578            if !rt.eq_ignore_ascii_case(&req.runtime) {
579                return Err(forbidden(format!(
580                    "worker `{}` must announce runtime `{rt}`",
581                    id.id
582                )));
583            }
584        }
585        let denied: Vec<&str> = req
586            .contracts
587            .iter()
588            .map(|c| c.task_name.as_str())
589            .filter(|t| !id.may_serve(t))
590            .collect();
591        if !denied.is_empty() {
592            return Err(forbidden(format!(
593                "worker `{}` may not serve: {} (allowed: {})",
594                id.id,
595                denied.join(", "),
596                id.tasks.join(", ")
597            )));
598        }
599    }
600    Ok(Json(
601        serde_json::to_value(engine(&state).register_worker(req)?).unwrap_or_default(),
602    ))
603}
604
605#[derive(Deserialize)]
606struct SessionRequest {
607    worker_session_id: String,
608    #[serde(default)]
609    wait_ms: Option<u64>,
610}
611
612async fn heartbeat(
613    State(state): State<Shared>,
614    Extension(caller): Extension<Caller>,
615    Json(req): Json<SessionRequest>,
616) -> ApiResult<Json<Value>> {
617    authorize_session(&state, &caller, &req.worker_session_id)?;
618    Ok(Json(
619        serde_json::to_value(engine(&state).heartbeat(&req.worker_session_id)?).unwrap_or_default(),
620    ))
621}
622
623async fn unregister(
624    State(state): State<Shared>,
625    Extension(caller): Extension<Caller>,
626    Json(req): Json<SessionRequest>,
627) -> ApiResult<Json<Value>> {
628    authorize_session(&state, &caller, &req.worker_session_id)?;
629    engine(&state).unregister_worker(&req.worker_session_id)?;
630    Ok(Json(json!({ "accepted": true })))
631}
632
633/// Long-poll for a task. Returns 204 when nothing arrived within `wait_ms`.
634async fn poll(
635    State(state): State<Shared>,
636    Extension(caller): Extension<Caller>,
637    Json(req): Json<SessionRequest>,
638) -> ApiResult<Response> {
639    authorize_session(&state, &caller, &req.worker_session_id)?;
640    let wait = Duration::from_millis(req.wait_ms.unwrap_or(20_000).min(60_000));
641    let deadline = tokio::time::Instant::now() + wait;
642    loop {
643        let notified = state.changed.notified();
644        tokio::pin!(notified);
645        notified.as_mut().enable();
646        let task: Option<TaskAssignment> = crate::shared::poll(&state, &req.worker_session_id)?;
647        if let Some(t) = task {
648            return Ok(Json(serde_json::to_value(t).unwrap_or_default()).into_response());
649        }
650        if tokio::time::Instant::now() >= deadline {
651            return Ok(StatusCode::NO_CONTENT.into_response());
652        }
653        let _ = tokio::time::timeout_at(deadline, notified).await;
654    }
655}
656
657#[derive(Deserialize)]
658struct CompleteRequest {
659    token: String,
660    #[serde(default)]
661    output: Value,
662}
663
664async fn complete_task(
665    State(state): State<Shared>,
666    Extension(caller): Extension<Caller>,
667    Json(req): Json<CompleteRequest>,
668) -> ApiResult<Json<Value>> {
669    if req.token.is_empty() {
670        return Err(bad_request("token is required"));
671    }
672    authorize_token(&state, &caller, &req.token)?;
673    Ok(Json(
674        serde_json::to_value(engine(&state).complete(&req.token, req.output)?).unwrap_or_default(),
675    ))
676}
677
678#[derive(Deserialize)]
679struct FailRequest {
680    token: String,
681    error_code: String,
682    #[serde(default)]
683    error_message: String,
684    #[serde(default = "default_true")]
685    retryable: bool,
686    #[serde(default)]
687    details: Option<Value>,
688}
689
690fn default_true() -> bool {
691    true
692}
693
694async fn fail_task(
695    State(state): State<Shared>,
696    Extension(caller): Extension<Caller>,
697    Json(req): Json<FailRequest>,
698) -> ApiResult<Json<Value>> {
699    if req.error_code.is_empty() {
700        return Err(bad_request("error_code is required"));
701    }
702    authorize_token(&state, &caller, &req.token)?;
703    let error = TaskError {
704        code: req.error_code,
705        message: req.error_message,
706        retryable: req.retryable,
707        details: req.details,
708    };
709    Ok(Json(
710        serde_json::to_value(engine(&state).fail(&req.token, error)?).unwrap_or_default(),
711    ))
712}
713
714/// Background loop: timers, retries, deadlines, liveness and built-in tasks.
715pub async fn run_ticker(state: Shared, interval: Duration) {
716    let mut ticker = tokio::time::interval(interval);
717    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
718    loop {
719        ticker.tick().await;
720        let jobs = match engine(&state).tick() {
721            Ok(j) => j,
722            Err(e) => {
723                tracing::error!(error = %e, "tick failed");
724                continue;
725            }
726        };
727        for job in jobs {
728            let st = state.clone();
729            tokio::spawn(async move {
730                let input = job.input.clone();
731                let timeout = job.timeout_ms;
732                let result = tokio::task::spawn_blocking(move || {
733                    crate::http_adapter::execute(&input, timeout)
734                })
735                .await
736                .unwrap_or_else(|e| Err(TaskError::new("INTERNAL", e.to_string(), true)));
737                let mut e = engine(&st);
738                let r = match result {
739                    Ok(out) => e.complete(&job.token, out),
740                    Err(err) => e.fail(&job.token, err),
741                };
742                if let Err(err) = r {
743                    tracing::error!(error = %err, "recording built-in task result failed");
744                }
745            });
746        }
747    }
748}
749
750/// PEM files for serving HTTPS directly.
751pub struct TlsConfig {
752    pub cert: std::path::PathBuf,
753    pub key: std::path::PathBuf,
754    /// Enables mutual TLS: client certificates signed by this CA are verified
755    /// and identify workers. Clients without a certificate can still connect
756    /// and authenticate with a token.
757    pub client_ca: Option<std::path::PathBuf>,
758}
759
760fn io_err(msg: String) -> std::io::Error {
761    std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
762}
763
764fn read_pem_certs(
765    path: &std::path::Path,
766) -> std::io::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
767    let pem =
768        std::fs::read(path).map_err(|e| io_err(format!("cannot read {}: {e}", path.display())))?;
769    let certs = rustls_pemfile::certs(&mut pem.as_slice())
770        .collect::<Result<Vec<_>, _>>()
771        .map_err(|e| io_err(format!("{}: {e}", path.display())))?;
772    if certs.is_empty() {
773        return Err(io_err(format!(
774            "{} contains no certificates",
775            path.display()
776        )));
777    }
778    Ok(certs)
779}
780
781/// Build the rustls server configuration (with client verification for mTLS).
782fn rustls_config(tls: &TlsConfig) -> std::io::Result<rustls::ServerConfig> {
783    let chain = read_pem_certs(&tls.cert)?;
784    let key_pem = std::fs::read(&tls.key)
785        .map_err(|e| io_err(format!("cannot read {}: {e}", tls.key.display())))?;
786    let key = rustls_pemfile::private_key(&mut key_pem.as_slice())
787        .map_err(|e| io_err(format!("{}: {e}", tls.key.display())))?
788        .ok_or_else(|| io_err(format!("{} contains no private key", tls.key.display())))?;
789    let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
790    let builder = rustls::ServerConfig::builder_with_provider(provider.clone())
791        .with_safe_default_protocol_versions()
792        .map_err(|e| io_err(e.to_string()))?;
793    let builder = match &tls.client_ca {
794        Some(ca) => {
795            let mut roots = rustls::RootCertStore::empty();
796            for c in read_pem_certs(ca)? {
797                roots
798                    .add(c)
799                    .map_err(|e| io_err(format!("{}: {e}", ca.display())))?;
800            }
801            let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(
802                Arc::new(roots),
803                provider,
804            )
805            .allow_unauthenticated()
806            .build()
807            .map_err(|e| io_err(format!("client CA: {e}")))?;
808            builder.with_client_cert_verifier(verifier)
809        }
810        None => builder.with_no_client_auth(),
811    };
812    let mut config = builder
813        .with_single_cert(chain, key)
814        .map_err(|e| io_err(format!("TLS certificate/key: {e}")))?;
815    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
816    Ok(config)
817}
818
819pub struct ServerConfig {
820    pub addr: SocketAddr,
821    pub auth_token: Option<String>,
822    pub worker_token: Option<String>,
823    pub identities: Identities,
824    pub tls: Option<TlsConfig>,
825    /// Also serve the gRPC binding of the worker protocol on this address.
826    pub grpc_addr: Option<SocketAddr>,
827    /// Pre-bound listeners (socket activation, tests): used instead of
828    /// binding `addr` / `grpc_addr`.
829    pub listener: Option<std::net::TcpListener>,
830    pub grpc_listener: Option<std::net::TcpListener>,
831    pub body_limit: usize,
832    pub tick_interval: Duration,
833}
834
835pub async fn serve(
836    engine: Engine,
837    config: ServerConfig,
838    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
839) -> std::io::Result<()> {
840    serve_node(engine, config, None, shutdown).await
841}
842
843/// [`serve`] as a member of a cluster (`orbit server --cluster`): `engine`
844/// is the node's standby engine ([`crate::cluster::Node::standby_engine`]);
845/// the node's election thread swaps in a leader engine once elected.
846pub async fn serve_node(
847    engine: Engine,
848    config: ServerConfig,
849    cluster: Option<Arc<crate::cluster::Node>>,
850    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
851) -> std::io::Result<()> {
852    // Several rustls crypto backends are compiled in (via different
853    // dependencies); choose one before any TLS configuration is built.
854    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
855    let changed = engine.changed.clone();
856    // Shared mode (`--cluster-mode shared`): the engine carries its context.
857    let shared = engine.shared_context();
858    let state = Arc::new(AppState {
859        engine: Mutex::new(engine),
860        changed,
861        auth_token: config.auth_token,
862        worker_token: config.worker_token,
863        identities: config.identities,
864    });
865    let app = router(state.clone(), config.body_limit);
866    let app = match &shared {
867        Some(ctx) => crate::shared::layer(app, ctx.clone(), state.clone()),
868        None => crate::cluster::layer(app, cluster.clone(), state.clone()),
869    };
870    if let Some(node) = &cluster {
871        crate::cluster::start(node.clone(), state.clone())?;
872    }
873    if let Some(ctx) = &shared {
874        crate::shared::start(ctx.clone(), state.clone())?;
875    }
876    let ticker = tokio::spawn(run_ticker(state.clone(), config.tick_interval));
877    // One shutdown signal fanned out to every listener.
878    let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
879    tokio::spawn(async move {
880        shutdown.await;
881        let _ = stop_tx.send(true);
882    });
883    let stopped = |mut rx: tokio::sync::watch::Receiver<bool>| async move {
884        let _ = rx.wait_for(|v| *v).await;
885    };
886    let bind = |addr: SocketAddr| {
887        std::net::TcpListener::bind(addr)
888            .map_err(|e| std::io::Error::new(e.kind(), format!("cannot listen on {addr}: {e}")))
889    };
890    let grpc_listener = match (config.grpc_listener, config.grpc_addr) {
891        (Some(l), _) => Some(l),
892        (None, Some(addr)) => Some(bind(addr)?),
893        (None, None) => None,
894    };
895    let http_listener = match config.listener {
896        Some(l) => l,
897        None => bind(config.addr)?,
898    };
899    http_listener.set_nonblocking(true)?;
900    let http_addr = http_listener.local_addr()?;
901    let grpc = match grpc_listener {
902        Some(std_listener) => {
903            std_listener.set_nonblocking(true)?;
904            let addr = std_listener.local_addr()?;
905            let listener = tokio::net::TcpListener::from_std(std_listener)?;
906            let incoming =
907                tonic::transport::server::TcpIncoming::from_listener(listener, true, None)
908                    .map_err(|e| io_err(format!("gRPC listener: {e}")))?;
909            let mut builder = tonic::transport::Server::builder();
910            if let Some(tls) = &config.tls {
911                let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
912                let read = |p: &std::path::Path| {
913                    std::fs::read(p)
914                        .map_err(|e| io_err(format!("cannot read {}: {e}", p.display())))
915                };
916                let mut t = tonic::transport::ServerTlsConfig::new().identity(
917                    tonic::transport::Identity::from_pem(read(&tls.cert)?, read(&tls.key)?),
918                );
919                if let Some(ca) = &tls.client_ca {
920                    t = t
921                        .client_ca_root(tonic::transport::Certificate::from_pem(read(ca)?))
922                        .client_auth_optional(true);
923                }
924                builder = builder
925                    .tls_config(t)
926                    .map_err(|e| io_err(format!("gRPC TLS: {e}")))?;
927            }
928            let service = crate::grpc::GrpcWorkers::new(state.clone());
929            let rx = stop_rx.clone();
930            tracing::info!(addr = %addr, tls = config.tls.is_some(), "gRPC worker protocol listening");
931            let router = match &cluster {
932                Some(node) => {
933                    builder.add_service(tonic::service::interceptor::InterceptedService::new(
934                        service,
935                        crate::cluster::grpc_gate(node.clone()),
936                    ))
937                }
938                None => builder.add_service(service),
939            };
940            Some(tokio::spawn(async move {
941                if let Err(e) = router
942                    .serve_with_incoming_shutdown(incoming, stopped(rx))
943                    .await
944                {
945                    tracing::error!(error = %e, "gRPC server failed");
946                }
947            }))
948        }
949        None => None,
950    };
951    let shutdown = stopped(stop_rx);
952    let result = match config.tls {
953        None => {
954            let listener = tokio::net::TcpListener::from_std(http_listener)?;
955            tracing::info!(addr = %http_addr, tls = false, "ORBIT runtime listening");
956            axum::serve(listener, app)
957                .with_graceful_shutdown(shutdown)
958                .await
959        }
960        Some(tls) => {
961            // Several rustls crypto backends are compiled in (via different
962            // dependencies); pick one explicitly so the choice is not ambiguous.
963            let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
964            let rustls =
965                axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(rustls_config(&tls)?));
966            let handle = axum_server::Handle::new();
967            let h = handle.clone();
968            tokio::spawn(async move {
969                shutdown.await;
970                h.graceful_shutdown(Some(Duration::from_secs(10)));
971            });
972            tracing::info!(addr = %http_addr, tls = true, mtls = tls.client_ca.is_some(), "ORBIT runtime listening");
973            axum_server::from_tcp_rustls(http_listener, rustls)?
974                .handle(handle)
975                .serve(app.into_make_service())
976                .await
977        }
978    };
979    ticker.abort();
980    if let Some(ctx) = &shared {
981        ctx.stop();
982    }
983    if let Some(g) = grpc {
984        let _ = g.await;
985    }
986    result
987}