Skip to main content

faucet_cli/serve/handlers/
runs.rs

1//! `/v1/runs*` HTTP handlers. Thin glue: deserialize, call into `runner`/history,
2//! map to status codes. All run-mutating logic lives in `runner.rs`.
3
4use crate::serve::error::ServeError;
5use crate::serve::history::{DeleteOutcome, ListFilter, RunRecord, RunStatus};
6use crate::serve::rbac::AuthContext;
7use crate::serve::runner::{self, SubmitRequest, SubmitResponse};
8use crate::serve::state::ServerState;
9use axum::Json;
10use axum::extract::{Extension, Path, Query, State};
11use axum::http::StatusCode;
12use axum::response::IntoResponse;
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16/// Scrub any resolved secret that reached a run's error fields before the record
17/// is serialized into an HTTP response. The serve log subscriber's redaction
18/// writer only covers tracing/log output — API response bodies are a separate
19/// egress and must be scrubbed here.
20fn redact_record(rec: &mut RunRecord) {
21    if let Some(e) = &rec.error {
22        rec.error = Some(crate::secrets::registry::redact(e).into_owned());
23    }
24    for inv in &mut rec.invocations {
25        if let Some(e) = &inv.error {
26            inv.error = Some(crate::secrets::registry::redact(e).into_owned());
27        }
28    }
29}
30
31/// `POST /v1/runs` → 202. The auth middleware injects the resolved
32/// [`AuthContext`] so `submit` can attribute the `run.submit` audit record.
33pub async fn submit_run(
34    State(state): State<ServerState>,
35    Extension(actor): Extension<AuthContext>,
36    Json(req): Json<SubmitRequest>,
37) -> Result<(StatusCode, Json<SubmitResponse>), ServeError> {
38    let resp = runner::submit(state, req, actor).await?;
39    Ok((StatusCode::ACCEPTED, Json(resp)))
40}
41
42/// `GET /v1/runs/{id}` → 200 RunRecord. Fills live `elapsed_secs` for running runs.
43pub async fn get_run(
44    State(state): State<ServerState>,
45    Path(id): Path<String>,
46) -> Result<Json<RunRecord>, ServeError> {
47    let mut rec = state
48        .history()
49        .get(&id)
50        .await
51        .map_err(|e| ServeError::Internal(e.to_string()))?
52        .ok_or(ServeError::NotFound)?;
53    if rec.status == RunStatus::Running
54        && let Some(started) = rec.started_at
55    {
56        rec.elapsed_secs = (Utc::now() - started)
57            .to_std()
58            .ok()
59            .map(|d| d.as_secs_f64());
60    }
61    redact_record(&mut rec);
62    Ok(Json(rec))
63}
64
65/// `POST /v1/runs/{id}/cancel` → 202 (cancel requested) / 200 (terminal no-op) / 404.
66/// In cluster mode, cancel is best-effort: the run may complete on its owning
67/// instance before that instance processes the flag.
68pub async fn cancel_run(
69    State(state): State<ServerState>,
70    Extension(actor): Extension<AuthContext>,
71    Path(id): Path<String>,
72) -> Result<impl IntoResponse, ServeError> {
73    // 1. A live local token (this instance is running/queued it) → cancel now.
74    if state.registry().cancel(&id) {
75        crate::serve::audit::write(&state, &actor, "run.cancel", Some(id.clone()), None, "ok")
76            .await;
77        return Ok(StatusCode::ACCEPTED);
78    }
79
80    // 2. Look up the record.
81    let rec = match state
82        .history()
83        .get(&id)
84        .await
85        .map_err(|e| ServeError::Internal(e.to_string()))?
86    {
87        Some(r) => r,
88        None => return Err(ServeError::NotFound),
89    };
90
91    if rec.status.is_terminal() {
92        return Ok(StatusCode::OK); // already done — no-op.
93    }
94
95    // 3. Cluster mode: the run is owned by another instance (or unclaimed).
96    if state.cluster().enabled() {
97        // Unclaimed (Pending) → cancel it directly.
98        if state
99            .history()
100            .cancel_pending(&id)
101            .await
102            .map_err(|e| ServeError::Internal(e.to_string()))?
103        {
104            crate::serve::audit::write(&state, &actor, "run.cancel", Some(id.clone()), None, "ok")
105                .await;
106            // Terminal transition that bypasses `runner::finalize` entirely, so
107            // it must fire the completion callback itself (#481).
108            match state.history().get(&id).await {
109                Ok(Some(rec)) => crate::serve::callback::fire(&rec).await,
110                Ok(None) => {}
111                Err(e) => tracing::warn!(
112                    run_id = %id,
113                    error = %e,
114                    "could not read cancelled run for its completion callback"
115                ),
116            }
117            return Ok(StatusCode::ACCEPTED);
118        }
119        // Otherwise it is running on a peer → flag it; the peer cancels on its
120        // next claim-loop tick.
121        state
122            .history()
123            .request_cancel(&id)
124            .await
125            .map_err(|e| ServeError::Internal(e.to_string()))?;
126        crate::serve::audit::write(&state, &actor, "run.cancel", Some(id.clone()), None, "ok")
127            .await;
128        return Ok(StatusCode::ACCEPTED);
129    }
130
131    // Single-instance, non-terminal, no local token: a transient race (the token
132    // was removed between the registry check and the history read as the run
133    // completes). Treat as a no-op — the run is finishing or just finished.
134    Ok(StatusCode::OK)
135}
136
137/// `DELETE /v1/runs/{id}` → 204 / 404 / 409 (still running).
138pub async fn delete_run(
139    State(state): State<ServerState>,
140    Extension(actor): Extension<AuthContext>,
141    Path(id): Path<String>,
142) -> Result<StatusCode, ServeError> {
143    match state
144        .history()
145        .delete(&id)
146        .await
147        .map_err(|e| ServeError::Internal(e.to_string()))?
148    {
149        DeleteOutcome::Deleted => {
150            crate::serve::audit::write(&state, &actor, "run.delete", Some(id.clone()), None, "ok")
151                .await;
152            Ok(StatusCode::NO_CONTENT)
153        }
154        DeleteOutcome::NotFound => Err(ServeError::NotFound),
155        DeleteOutcome::StillRunning => Err(ServeError::Conflict(
156            "run is still in flight — cancel it before deleting".into(),
157        )),
158    }
159}
160
161/// Query-param wrapper for an RFC3339 timestamp. `application/x-www-form-urlencoded`
162/// decodes `+` as a space, which corrupts explicit UTC offsets like `+05:30`; we
163/// restore the `+` before parsing, so both `…Z` and `…+05:30` query values work.
164#[derive(Debug, Clone, Copy)]
165pub(crate) struct DateTimeUtcParam(DateTime<Utc>);
166
167impl<'de> Deserialize<'de> for DateTimeUtcParam {
168    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
169        let raw = String::deserialize(d)?;
170        // Undo form-encoding's '+' → ' ' substitution before RFC3339 parsing.
171        let restored = raw.replace(' ', "+");
172        DateTime::parse_from_rfc3339(&restored)
173            .map(|dt| DateTimeUtcParam(dt.to_utc()))
174            .map_err(serde::de::Error::custom)
175    }
176}
177
178/// `GET /v1/runs` query string.
179#[derive(Debug, Deserialize)]
180pub struct ListQuery {
181    pub status: Option<RunStatus>,
182    pub name: Option<String>,
183    pub(crate) since: Option<DateTimeUtcParam>,
184    pub(crate) until: Option<DateTimeUtcParam>,
185    pub limit: Option<usize>,
186    pub cursor: Option<String>,
187}
188
189/// `GET /v1/runs` response body.
190#[derive(Debug, Serialize)]
191pub struct ListResponse {
192    pub runs: Vec<RunRecord>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub next_cursor: Option<String>,
195}
196
197const DEFAULT_LIMIT: usize = 50;
198const MAX_LIMIT: usize = 500;
199
200impl ListQuery {
201    fn into_filter(self) -> ListFilter {
202        ListFilter {
203            status: self.status,
204            name: self.name,
205            since: self.since.map(|p| p.0),
206            until: self.until.map(|p| p.0),
207            limit: self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT),
208            cursor: self.cursor,
209        }
210    }
211}
212
213/// `GET /v1/runs` → 200.
214pub async fn list_runs(
215    State(state): State<ServerState>,
216    Query(query): Query<ListQuery>,
217) -> Result<Json<ListResponse>, ServeError> {
218    let page = state
219        .history()
220        .list(&query.into_filter())
221        .await
222        .map_err(|e| ServeError::Internal(e.to_string()))?;
223    let mut runs = page.runs;
224    for rec in &mut runs {
225        redact_record(rec);
226    }
227    Ok(Json(ListResponse {
228        runs,
229        next_cursor: page.next_cursor,
230    }))
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn datetime_param_restores_form_encoded_plus() {
239        // form-encoding turns the '+' of an offset into a space; the wrapper must
240        // restore it so both offset and Z forms parse to the same UTC instant.
241        let spaced: DateTimeUtcParam =
242            serde_json::from_value(serde_json::json!("2026-01-01T00:00:00 05:30")).unwrap();
243        let plus = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00+05:30")
244            .unwrap()
245            .to_utc();
246        assert_eq!(spaced.0, plus);
247
248        let zulu: DateTimeUtcParam =
249            serde_json::from_value(serde_json::json!("2026-01-01T00:00:00Z")).unwrap();
250        assert_eq!(
251            zulu.0,
252            chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
253                .unwrap()
254                .to_utc()
255        );
256    }
257
258    #[test]
259    fn list_query_clamps_limit() {
260        let q = ListQuery {
261            status: None,
262            name: None,
263            since: None,
264            until: None,
265            limit: Some(99999),
266            cursor: None,
267        };
268        assert_eq!(q.into_filter().limit, MAX_LIMIT);
269        let q = ListQuery {
270            status: Some(RunStatus::Failed),
271            name: None,
272            since: None,
273            until: None,
274            limit: None,
275            cursor: None,
276        };
277        let f = q.into_filter();
278        assert_eq!(f.limit, DEFAULT_LIMIT);
279        assert_eq!(f.status, Some(RunStatus::Failed));
280    }
281
282    #[tokio::test]
283    async fn cancel_pending_run_in_cluster_mode_cancels_it() {
284        use crate::serve::history::{RunRecord, RunStatus};
285        use crate::serve::test_support::test_state_clustered;
286        use chrono::Utc;
287
288        let state = test_state_clustered();
289        // A pending run with no local token (simulating an unclaimed cluster run).
290        let mut rec = RunRecord::queued("p1".into(), None, Default::default(), None, Utc::now());
291        rec.status = RunStatus::Pending;
292        state.history().upsert(&rec).await.unwrap();
293
294        let resp = cancel_run(
295            axum::extract::State(state.clone()),
296            axum::extract::Extension(AuthContext {
297                principal: "test".into(),
298                role: crate::serve::rbac::Role::Admin,
299                source_ip: None,
300            }),
301            axum::extract::Path("p1".into()),
302        )
303        .await
304        .unwrap()
305        .into_response();
306        assert_eq!(resp.status(), StatusCode::ACCEPTED);
307        assert_eq!(
308            state.history().get("p1").await.unwrap().unwrap().status,
309            RunStatus::Cancelled
310        );
311    }
312}