faucet_cli/serve/handlers/
runs.rs1use 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
16fn 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
31pub 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
42pub 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
65pub 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 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 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); }
94
95 if state.cluster().enabled() {
97 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 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 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 Ok(StatusCode::OK)
135}
136
137pub 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#[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 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#[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#[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
213pub 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 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 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}