1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::{SearchAttributeValue, WorkflowId, WorkflowStatus, WorkflowSummary};
16
17pub const NAMESPACE_ATTRIBUTE: &str = "aion.namespace";
22
23pub const DEFAULT_NAMESPACE: &str = "default";
29
30#[must_use]
34pub fn namespace_from_attributes<S: std::hash::BuildHasher>(
35 attributes: &std::collections::HashMap<String, SearchAttributeValue, S>,
36) -> String {
37 match attributes.get(NAMESPACE_ATTRIBUTE) {
38 Some(SearchAttributeValue::String(namespace)) => namespace.clone(),
39 _ => String::from(DEFAULT_NAMESPACE),
40 }
41}
42
43pub const INTERNAL_WORKFLOW_TYPES: &[&str] = &["aion.schedule_coordinator"];
50
51#[must_use]
53pub fn is_internal_workflow_type(workflow_type: &str) -> bool {
54 INTERNAL_WORKFLOW_TYPES.contains(&workflow_type)
55}
56
57#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
59#[serde(rename_all = "snake_case")]
60pub enum WorkflowKind {
61 Workflow,
63 Workloop,
65}
66
67impl WorkflowKind {
68 #[must_use]
71 pub fn matches(self, recorded_kind: Option<&str>) -> bool {
72 match self {
73 Self::Workflow => recorded_kind != Some(crate::WORKLOOP_KIND),
74 Self::Workloop => recorded_kind == Some(crate::WORKLOOP_KIND),
75 }
76 }
77}
78
79#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, Default, PartialEq, Eq)]
82pub struct WorkflowListFilter {
83 #[serde(default)]
85 pub kind: Option<WorkflowKind>,
86 #[serde(default)]
89 pub workflow_types: Vec<String>,
90 #[serde(default)]
92 pub statuses: Vec<WorkflowStatus>,
93 #[serde(default)]
95 pub started_after: Option<DateTime<Utc>>,
96 #[serde(default)]
98 pub started_before: Option<DateTime<Utc>>,
99 #[serde(default)]
101 pub updated_after: Option<DateTime<Utc>>,
102 #[serde(default)]
104 pub updated_before: Option<DateTime<Utc>>,
105 #[serde(default)]
107 pub parent: Option<WorkflowId>,
108 #[serde(default)]
112 pub text: Option<String>,
113}
114
115impl WorkflowListFilter {
116 #[must_use]
124 pub fn matches(&self, summary: &WorkflowSummary) -> bool {
125 self.kind
126 .is_none_or(|kind| kind.matches(summary.kind.as_deref()))
127 && self.matches_workflow_type(&summary.workflow_type)
128 && (self.statuses.is_empty() || self.statuses.contains(&summary.status))
129 && self
130 .started_after
131 .is_none_or(|bound| summary.started_at >= bound)
132 && self
133 .started_before
134 .is_none_or(|bound| summary.started_at <= bound)
135 && self
136 .updated_after
137 .is_none_or(|bound| summary.updated_at >= bound)
138 && self
139 .updated_before
140 .is_none_or(|bound| summary.updated_at <= bound)
141 && self
142 .parent
143 .as_ref()
144 .is_none_or(|parent| summary.parent.as_ref() == Some(parent))
145 && self.matches_text(summary)
146 }
147
148 fn matches_workflow_type(&self, workflow_type: &str) -> bool {
149 if self.workflow_types.is_empty() {
150 !is_internal_workflow_type(workflow_type)
151 } else {
152 self.workflow_types
153 .iter()
154 .any(|named| named == workflow_type)
155 }
156 }
157
158 #[must_use]
160 pub fn text_needle(&self) -> Option<&str> {
161 self.text
162 .as_deref()
163 .map(str::trim)
164 .filter(|needle| !needle.is_empty())
165 }
166
167 fn matches_text(&self, summary: &WorkflowSummary) -> bool {
168 let Some(needle) = self.text_needle() else {
169 return true;
170 };
171 let lowered = needle.to_lowercase();
172 let by_name = summary
173 .display_name
174 .as_deref()
175 .is_some_and(|name| name.to_lowercase().contains(&lowered));
176 let by_id = summary
177 .workflow_id
178 .to_string()
179 .to_lowercase()
180 .starts_with(&lowered);
181 by_name || by_id
182 }
183}
184
185#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
187#[serde(rename_all = "snake_case")]
188pub enum WorkflowSortField {
189 StartedAt,
191 UpdatedAt,
193 EndedAt,
195 WorkflowType,
197 Status,
199 DisplayName,
201}
202
203impl WorkflowSortField {
204 pub const ALL: [Self; 6] = [
206 Self::StartedAt,
207 Self::UpdatedAt,
208 Self::EndedAt,
209 Self::WorkflowType,
210 Self::Status,
211 Self::DisplayName,
212 ];
213}
214
215#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
217#[serde(rename_all = "snake_case")]
218pub enum SortDirection {
219 Asc,
221 Desc,
223}
224
225#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
227pub struct WorkflowSort {
228 pub field: WorkflowSortField,
230 pub direction: SortDirection,
232}
233
234#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
236pub struct WorkflowListRequest {
237 pub namespace: String,
239 #[serde(default)]
241 pub filter: WorkflowListFilter,
242 pub sort: WorkflowSort,
244 #[serde(default)]
248 pub cursor: Option<String>,
249 pub limit: u32,
251}
252
253#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
255pub struct WorkflowListPage {
256 pub items: Vec<WorkflowSummary>,
258 pub next_cursor: Option<String>,
260 pub count: u64,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub provenance: Option<crate::ReadProvenance>,
272}
273
274#[cfg(test)]
277#[path = "listing_text_cases.rs"]
278mod listing_text_cases;
279
280#[cfg(test)]
281mod tests {
282 use chrono::{DateTime, Utc};
283
284 use super::{WorkflowKind, WorkflowListFilter};
285 use crate::{RunId, WorkflowId, WorkflowStatus, WorkflowSummary};
286
287 fn summary(display_name: Option<&str>, kind: Option<&str>) -> WorkflowSummary {
288 WorkflowSummary {
289 workflow_id: WorkflowId::new(uuid::Uuid::from_u128(
290 0xabc0_0000_0000_0000_0000_0000_0000_0001,
291 )),
292 run_id: RunId::new_v4(),
293 workflow_type: String::from("checkout"),
294 status: WorkflowStatus::Running,
295 started_at: DateTime::<Utc>::default(),
296 updated_at: DateTime::<Utc>::default() + chrono::Duration::seconds(5),
297 ended_at: None,
298 parent: None,
299 failed_step: None,
300 failure_reason: None,
301 display_name: display_name.map(str::to_owned),
302 kind: kind.map(str::to_owned),
303 current_worker: None,
304 package_version: None,
305 }
306 }
307
308 #[test]
309 fn empty_filter_matches_everything() {
310 assert!(WorkflowListFilter::default().matches(&summary(None, None)));
311 }
312
313 #[test]
314 fn kind_predicate_reads_the_recorded_attribute() {
315 assert!(WorkflowKind::Workflow.matches(None));
316 assert!(!WorkflowKind::Workflow.matches(Some("workloop")));
317 assert!(WorkflowKind::Workloop.matches(Some("workloop")));
318 assert!(!WorkflowKind::Workloop.matches(None));
319 }
320
321 #[test]
322 fn text_matches_display_name_substring_case_insensitively() {
323 let filter = WorkflowListFilter {
324 text: Some(String::from(" NIGHTLY ")),
325 ..WorkflowListFilter::default()
326 };
327 assert!(filter.matches(&summary(Some("the nightly build"), None)));
328 assert!(!filter.matches(&summary(Some("weekly build"), None)));
329 assert!(!filter.matches(&summary(None, None)));
330 }
331
332 #[test]
333 fn text_matches_workflow_id_prefix() {
334 let filter = WorkflowListFilter {
335 text: Some(String::from("ABC00000")),
336 ..WorkflowListFilter::default()
337 };
338 assert!(filter.matches(&summary(None, None)));
339 let miss = WorkflowListFilter {
340 text: Some(String::from("bc00000")),
341 ..WorkflowListFilter::default()
342 };
343 assert!(
344 !miss.matches(&summary(None, None)),
345 "a prefix, not a substring"
346 );
347 }
348
349 #[test]
350 fn internal_types_hide_unless_named() {
351 let mut internal = summary(None, None);
352 internal.workflow_type = String::from("aion.schedule_coordinator");
353 assert!(!WorkflowListFilter::default().matches(&internal));
354 let named = WorkflowListFilter {
355 workflow_types: vec![String::from("aion.schedule_coordinator")],
356 ..WorkflowListFilter::default()
357 };
358 assert!(named.matches(&internal));
359 assert!(!named.matches(&summary(None, None)));
360 }
361
362 #[test]
363 fn blank_text_is_absent() {
364 let filter = WorkflowListFilter {
365 text: Some(String::from(" ")),
366 ..WorkflowListFilter::default()
367 };
368 assert_eq!(filter.text_needle(), None);
369 assert!(filter.matches(&summary(None, None)));
370 }
371
372 #[test]
373 fn list_predicates_are_any_of_and_bounds_are_inclusive() {
374 let row = summary(None, None);
375 let filter = WorkflowListFilter {
376 workflow_types: vec![String::from("other"), String::from("checkout")],
377 statuses: vec![WorkflowStatus::Completed, WorkflowStatus::Running],
378 started_after: Some(row.started_at),
379 started_before: Some(row.started_at),
380 updated_after: Some(row.updated_at),
381 updated_before: Some(row.updated_at),
382 ..WorkflowListFilter::default()
383 };
384 assert!(filter.matches(&row));
385 let excluded = WorkflowListFilter {
386 statuses: vec![WorkflowStatus::Completed],
387 ..WorkflowListFilter::default()
388 };
389 assert!(!excluded.matches(&row));
390 let too_late = WorkflowListFilter {
391 updated_after: Some(row.updated_at + chrono::Duration::seconds(1)),
392 ..WorkflowListFilter::default()
393 };
394 assert!(!too_late.matches(&row));
395 }
396}