faucet_cli/serve/handlers/
runs.rs1use 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
15fn 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
30pub 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
39pub 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
62pub async fn cancel_run(
66 State(state): State<ServerState>,
67 Path(id): Path<String>,
68) -> Result<impl IntoResponse, ServeError> {
69 if state.registry().cancel(&id) {
71 return Ok(StatusCode::ACCEPTED);
72 }
73
74 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); }
88
89 if state.cluster().enabled() {
91 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 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 Ok(StatusCode::OK)
114}
115
116pub 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#[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 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#[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#[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
187pub 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 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 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}