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(
64 State(state): State<ServerState>,
65 Path(id): Path<String>,
66) -> Result<impl IntoResponse, ServeError> {
67 if state.registry().cancel(&id) {
69 return Ok(StatusCode::ACCEPTED);
70 }
71 match state
73 .history()
74 .get(&id)
75 .await
76 .map_err(|e| ServeError::Internal(e.to_string()))?
77 {
78 Some(_) => Ok(StatusCode::OK),
79 None => Err(ServeError::NotFound),
80 }
81}
82
83pub async fn delete_run(
85 State(state): State<ServerState>,
86 Path(id): Path<String>,
87) -> Result<StatusCode, ServeError> {
88 match state
89 .history()
90 .delete(&id)
91 .await
92 .map_err(|e| ServeError::Internal(e.to_string()))?
93 {
94 DeleteOutcome::Deleted => Ok(StatusCode::NO_CONTENT),
95 DeleteOutcome::NotFound => Err(ServeError::NotFound),
96 DeleteOutcome::StillRunning => Err(ServeError::Conflict(
97 "run is still in flight — cancel it before deleting".into(),
98 )),
99 }
100}
101
102#[derive(Debug, Clone, Copy)]
106pub(crate) struct DateTimeUtcParam(DateTime<Utc>);
107
108impl<'de> Deserialize<'de> for DateTimeUtcParam {
109 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
110 let raw = String::deserialize(d)?;
111 let restored = raw.replace(' ', "+");
113 DateTime::parse_from_rfc3339(&restored)
114 .map(|dt| DateTimeUtcParam(dt.to_utc()))
115 .map_err(serde::de::Error::custom)
116 }
117}
118
119#[derive(Debug, Deserialize)]
121pub struct ListQuery {
122 pub status: Option<RunStatus>,
123 pub name: Option<String>,
124 pub(crate) since: Option<DateTimeUtcParam>,
125 pub(crate) until: Option<DateTimeUtcParam>,
126 pub limit: Option<usize>,
127 pub cursor: Option<String>,
128}
129
130#[derive(Debug, Serialize)]
132pub struct ListResponse {
133 pub runs: Vec<RunRecord>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub next_cursor: Option<String>,
136}
137
138const DEFAULT_LIMIT: usize = 50;
139const MAX_LIMIT: usize = 500;
140
141impl ListQuery {
142 fn into_filter(self) -> ListFilter {
143 ListFilter {
144 status: self.status,
145 name: self.name,
146 since: self.since.map(|p| p.0),
147 until: self.until.map(|p| p.0),
148 limit: self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT),
149 cursor: self.cursor,
150 }
151 }
152}
153
154pub async fn list_runs(
156 State(state): State<ServerState>,
157 Query(query): Query<ListQuery>,
158) -> Result<Json<ListResponse>, ServeError> {
159 let page = state
160 .history()
161 .list(&query.into_filter())
162 .await
163 .map_err(|e| ServeError::Internal(e.to_string()))?;
164 let mut runs = page.runs;
165 for rec in &mut runs {
166 redact_record(rec);
167 }
168 Ok(Json(ListResponse {
169 runs,
170 next_cursor: page.next_cursor,
171 }))
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn datetime_param_restores_form_encoded_plus() {
180 let spaced: DateTimeUtcParam =
183 serde_json::from_value(serde_json::json!("2026-01-01T00:00:00 05:30")).unwrap();
184 let plus = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00+05:30")
185 .unwrap()
186 .to_utc();
187 assert_eq!(spaced.0, plus);
188
189 let zulu: DateTimeUtcParam =
190 serde_json::from_value(serde_json::json!("2026-01-01T00:00:00Z")).unwrap();
191 assert_eq!(
192 zulu.0,
193 chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
194 .unwrap()
195 .to_utc()
196 );
197 }
198
199 #[test]
200 fn list_query_clamps_limit() {
201 let q = ListQuery {
202 status: None,
203 name: None,
204 since: None,
205 until: None,
206 limit: Some(99999),
207 cursor: None,
208 };
209 assert_eq!(q.into_filter().limit, MAX_LIMIT);
210 let q = ListQuery {
211 status: Some(RunStatus::Failed),
212 name: None,
213 since: None,
214 until: None,
215 limit: None,
216 cursor: None,
217 };
218 let f = q.into_filter();
219 assert_eq!(f.limit, DEFAULT_LIMIT);
220 assert_eq!(f.status, Some(RunStatus::Failed));
221 }
222}