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