Skip to main content

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}