af_workflow/result.rs
1//! What a step node returns. Port of `platform/types.py` Pass / Drop / FanOut.
2
3use af_context::CapabilityId;
4use chrono::{DateTime, Utc};
5use serde_json::Value;
6
7use crate::{event::Event, ResourceReservationRef};
8
9/// Product-owned action data emitted by an `execute.*` step. The durable
10/// driver adds tenant, run, lease, control and exact capability pins.
11#[derive(Debug, Clone, PartialEq)]
12pub struct PreparedAction {
13 /// Capability id the intent will pin.
14 pub capability_id: CapabilityId,
15 /// Product idempotency key; the driver scopes it to the instance and state version.
16 pub idempotency_key: String,
17 /// Structured input handed to the provider.
18 pub input: Value,
19 /// Resource scope the action competes in (empty when unscoped).
20 pub resource_scope_id: String,
21 /// Fenced reservation for funds actions.
22 pub reservation: Option<ResourceReservationRef>,
23 /// Latest time by which the action must finish.
24 pub deadline: Option<DateTime<Utc>>,
25}
26
27impl PreparedAction {
28 /// Unscoped action with no reservation or deadline.
29 pub fn new(
30 capability_id: CapabilityId,
31 idempotency_key: impl Into<String>,
32 input: Value,
33 ) -> Self {
34 Self {
35 capability_id,
36 idempotency_key: idempotency_key.into(),
37 input,
38 resource_scope_id: String::new(),
39 reservation: None,
40 deadline: None,
41 }
42 }
43
44 /// Reject blank capability or idempotency identifiers.
45 pub fn validate(&self) -> Result<(), PreparedActionError> {
46 if self.idempotency_key.trim().is_empty() {
47 return Err(PreparedActionError::MissingIdempotencyKey);
48 }
49 Ok(())
50 }
51}
52
53/// Invalid product action prepared for durable dispatch.
54#[derive(Debug, thiserror::Error, PartialEq, Eq)]
55pub enum PreparedActionError {
56 /// The product did not provide its idempotency key.
57 #[error("prepared action idempotency_key is required")]
58 MissingIdempotencyKey,
59}
60
61/// The outcome of running one event through one [`crate::node::StepNode`].
62#[derive(Debug, Clone)]
63pub enum StepResult {
64 /// Forward the (possibly derived) event to the next step.
65 Pass(Event),
66 /// Stop propagation here. Filters and sink terminals return this.
67 Drop {
68 /// Why the event stopped.
69 reason: String,
70 /// Stable machine-readable code for material business outcomes. A
71 /// routine filter drop leaves this empty.
72 exit_reason: Option<String>,
73 },
74 /// Forward several events. Only nodes that declare `produces_fan_out`
75 /// may return this; the validator bans fan-out upstream of `execute.*`.
76 FanOut(Vec<Event>),
77 /// Persist an external action intent and continue with the supplied event.
78 /// The step never invokes the provider inline.
79 Action {
80 /// Event forwarded to the next step.
81 event: Event,
82 /// The effect to persist as an intent.
83 action: Box<PreparedAction>,
84 },
85}
86
87impl StepResult {
88 /// A routine drop that recorders do not persist as a step.
89 pub fn drop(reason: impl Into<String>) -> Self {
90 StepResult::Drop {
91 reason: reason.into(),
92 exit_reason: None,
93 }
94 }
95
96 /// A material gate/drop that recorders should persist as a run step.
97 pub fn gate(exit_reason: impl Into<String>) -> Self {
98 let code = exit_reason.into();
99 StepResult::Drop {
100 reason: code.clone(),
101 exit_reason: Some(code),
102 }
103 }
104}