1use af_context::{InstanceId, NodeId, RunId, WorkflowDefinitionId};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(default, deny_unknown_fields)]
10pub struct WorkflowPage {
11 pub after: Option<String>,
13 pub limit: u32,
15}
16impl Default for WorkflowPage {
17 fn default() -> Self {
18 Self {
19 after: None,
20 limit: 50,
21 }
22 }
23}
24impl WorkflowPage {
25 pub fn validate(&self) -> Result<(), String> {
27 if !(1..=100).contains(&self.limit)
28 || self
29 .after
30 .as_ref()
31 .is_some_and(|s| s.trim().is_empty() || s.len() > 512)
32 {
33 return Err(
34 "workflow page requires limit 1..100 and a nonempty cursor of at most 512 bytes"
35 .into(),
36 );
37 }
38 Ok(())
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(tag = "resource", rename_all = "snake_case", deny_unknown_fields)]
45pub enum WorkflowQuery {
46 Definitions {
48 #[serde(default)]
50 include_archived: bool,
51 #[serde(default)]
53 page: WorkflowPage,
54 },
55 Instances {
57 #[serde(default)]
59 include_archived: bool,
60 #[serde(default)]
62 page: WorkflowPage,
63 },
64 Revisions {
66 definition_id: WorkflowDefinitionId,
68 #[serde(default)]
70 page: WorkflowPage,
71 },
72 Runs {
74 instance_id: InstanceId,
76 #[serde(default)]
78 page: WorkflowPage,
79 },
80 Steps {
82 run_id: RunId,
84 #[serde(default)]
86 page: WorkflowPage,
87 },
88 ActionFacts {
90 instance_id: InstanceId,
92 kind: WorkflowActionFactKind,
94 action_id: Option<String>,
96 #[serde(default)]
98 page: WorkflowPage,
99 },
100}
101impl WorkflowQuery {
102 pub fn validate(&self) -> Result<(), String> {
104 let page = match self {
105 Self::Definitions { page, .. }
106 | Self::Instances { page, .. }
107 | Self::Revisions { page, .. }
108 | Self::Runs { page, .. }
109 | Self::Steps { page, .. }
110 | Self::ActionFacts { page, .. } => page,
111 };
112 page.validate()?;
113 if let Self::Steps { run_id, .. } = self {
114 uuid::Uuid::parse_str(run_id.as_str()).map_err(|_| "invalid workflow run UUID")?;
115 }
116 if let Some(after) = &page.after {
117 match self {
118 Self::Revisions { .. } => {
119 after
120 .parse::<i64>()
121 .ok()
122 .filter(|n| *n >= 0)
123 .ok_or("invalid revision cursor")?;
124 }
125 Self::Runs { .. } | Self::Steps { .. } => {
126 uuid::Uuid::parse_str(after).map_err(|_| "invalid workflow UUID cursor")?;
127 }
128 Self::ActionFacts {
129 kind: WorkflowActionFactKind::Attempt,
130 ..
131 } => {
132 after
133 .parse::<u32>()
134 .ok()
135 .filter(|value| *value > 0)
136 .ok_or("invalid action attempt cursor")?;
137 }
138 _ => {}
139 }
140 }
141 if let Self::ActionFacts {
142 kind, action_id, ..
143 } = self
144 {
145 let has_action = action_id.as_ref().is_some_and(|id| !id.trim().is_empty());
146 if (*kind == WorkflowActionFactKind::Intent) == has_action {
147 return Err(
148 "action intent pages omit action_id; attempt/receipt/observation pages require it"
149 .into(),
150 );
151 }
152 }
153 Ok(())
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum WorkflowActionFactKind {
161 Intent,
163 Attempt,
165 Receipt,
167 Observation,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct WorkflowActionAttempt {
174 pub action_intent_id: String,
176 pub attempt: u32,
178 pub action_epoch: i64,
180 pub idempotency_key: String,
182 pub committed_at: DateTime<Utc>,
184 pub historical: bool,
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190#[serde(tag = "fact", content = "value", rename_all = "snake_case")]
191pub enum WorkflowActionFact {
192 Intent(crate::ActionIntent),
194 Attempt(WorkflowActionAttempt),
196 Receipt(crate::ActionReceipt),
198 Observation(crate::ActionObservation),
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct WorkflowDefinitionSummary {
205 pub definition_id: WorkflowDefinitionId,
207 pub name: String,
209 pub metadata_version: u64,
211 pub archived: bool,
213 pub can_manage: bool,
215 pub created_at: DateTime<Utc>,
217}
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct WorkflowRevisionSummary {
221 pub definition_id: WorkflowDefinitionId,
223 pub revision: u64,
225 pub content_digest: String,
227 pub contract: Value,
229}
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct WorkflowInstanceSummary {
233 pub name: String,
235 pub metadata_version: u64,
237 pub archived: bool,
239 pub instance_id: InstanceId,
241 pub definition_id: WorkflowDefinitionId,
243 pub revision: u64,
245 pub status: String,
247 pub control_mode: String,
249 pub next_run_at: DateTime<Utc>,
251 pub schedule_at: DateTime<Utc>,
253 pub starts_at: Option<DateTime<Utc>>,
255 pub expires_at: Option<DateTime<Utc>>,
257 pub drain_deadline: Option<DateTime<Utc>>,
259 pub lifecycle: crate::LifecyclePolicy,
261 pub last_error: Option<String>,
263}
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct WorkflowRunSummary {
267 pub run_id: RunId,
269 pub trigger_kind: String,
271 pub status: String,
273 pub exit_reason: Option<String>,
275 pub started_at: DateTime<Utc>,
277 pub finished_at: Option<DateTime<Utc>>,
279}
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct WorkflowStepSummary {
283 pub step_id: af_context::WorkflowStepId,
285 pub node_id: NodeId,
287 pub node_type: String,
289 pub status: String,
291 pub exit_reason: Option<String>,
293 pub detail: Value,
295 pub created_at: DateTime<Utc>,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum WorkflowVerdictKind {
303 Healthy,
305 Stalled,
307 SourceGap,
309 ProviderBlocked,
311 ActionUncertain,
313 Terminal,
315}
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(tag = "resource", rename_all = "snake_case")]
319pub enum WorkflowQueryResult {
320 Definitions {
322 items: Vec<WorkflowDefinitionSummary>,
324 next: Option<String>,
326 },
327 Instances {
329 items: Vec<WorkflowInstanceSummary>,
331 next: Option<String>,
333 },
334 Revisions {
336 items: Vec<WorkflowRevisionSummary>,
338 next: Option<String>,
340 },
341 Runs {
343 items: Vec<WorkflowRunSummary>,
345 next: Option<String>,
347 },
348 Steps {
350 items: Vec<WorkflowStepSummary>,
352 next: Option<String>,
354 },
355 ActionFacts {
357 items: Vec<WorkflowActionFact>,
359 next: Option<String>,
361 },
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 #[test]
368 fn bounded_query_rejects_unknown_fields_and_invalid_cursors() {
369 for resource in ["definitions", "instances", "revisions", "runs", "steps"] {
370 let mut value = serde_json::json!({"resource": resource, "page": {}, "definition_id":"definition", "instance_id":"instance", "run_id":"00000000-0000-0000-0000-000000000001"});
371 for field in ["definition_id", "instance_id", "run_id"] {
372 if !matches!(
373 (resource, field),
374 ("revisions", "definition_id") | ("runs", "instance_id") | ("steps", "run_id")
375 ) {
376 value.as_object_mut().unwrap().remove(field);
377 }
378 }
379 let parse = |value: &Value| serde_json::from_value::<WorkflowQuery>(value.clone());
380 assert!(parse(&value).unwrap().validate().is_ok());
381 for limit in [0, 101] {
382 value["page"]["limit"] = limit.into();
383 assert!(parse(&value).unwrap().validate().is_err());
384 }
385 value["page"]["limit"] = 100.into();
386 for after in ["".to_owned(), "x".repeat(513)] {
387 value["page"]["after"] = after.into();
388 assert!(parse(&value).unwrap().validate().is_err());
389 }
390 value["page"]["after"] = match resource {
391 "revisions" => "0",
392 "runs" | "steps" => "00000000-0000-0000-0000-000000000001",
393 _ => "last",
394 }
395 .into();
396 assert!(parse(&value).unwrap().validate().is_ok());
397 if matches!(resource, "revisions" | "runs" | "steps") {
398 value["page"]["after"] = "bad".into();
399 assert!(parse(&value).unwrap().validate().is_err());
400 }
401 if resource == "steps" {
402 value["run_id"] = "bad".into();
403 assert!(parse(&value).unwrap().validate().is_err());
404 }
405 value["subject_id"] = "other".into();
406 assert!(parse(&value).is_err());
407 }
408 let intent: WorkflowQuery = serde_json::from_value(serde_json::json!({
409 "resource":"action_facts","instance_id":"instance","kind":"intent","page":{}
410 }))
411 .unwrap();
412 assert!(intent.validate().is_ok());
413 let attempt: WorkflowQuery = serde_json::from_value(serde_json::json!({
414 "resource":"action_facts","instance_id":"instance","kind":"attempt","action_id":"action","page":{"after":"1"}
415 })).unwrap();
416 assert!(attempt.validate().is_ok());
417 for invalid in [
418 serde_json::json!({"resource":"action_facts","instance_id":"instance","kind":"intent","action_id":"action","page":{}}),
419 serde_json::json!({"resource":"action_facts","instance_id":"instance","kind":"receipt","page":{}}),
420 serde_json::json!({"resource":"action_facts","instance_id":"instance","kind":"attempt","action_id":"action","page":{"after":"bad"}}),
421 ] {
422 assert!(serde_json::from_value::<WorkflowQuery>(invalid)
423 .unwrap()
424 .validate()
425 .is_err());
426 }
427 }
428}