af-workflow 0.4.0

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow.
Documentation
//! Node trait + context. Port of `platform/node_protocol.py`.
//!
//! A spec has two node kinds. Ingress nodes are pure event sources and are
//! handled by the runner (next phase); this module models the **step node** —
//! a pure transformer: one event in, one [`StepResult`] out. State is the only
//! cross-run channel, scoped to the branch by id prefix.

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;

use crate::event::Event;
use crate::recorder::{noop_recorder, RunRecorder};
use crate::result::StepResult;
use crate::state::State;

/// The execution context handed to every step node.
///
/// Port of the subset of `WorkflowContext` a node legitimately touches:
/// branch id (for state scoping), instance config + metadata (for template
/// resolution), and the state handle. I/O tool calls (`ctx.do(...)`) are the
/// next porting phase.
pub struct WorkflowContext {
    /// Workflow branch this record belongs to.
    pub branch_id: String,
    /// Configuration object validated against the declared schema.
    pub config: Value,
    /// Instance metadata available to templates.
    pub instance_metadata: Value,
    /// Branch-scoped durable state handle.
    pub state: Arc<dyn State>,
    /// Run recorder receiving step facts.
    pub recorder: Arc<dyn RunRecorder>,
    /// Kind of trigger that started the run.
    pub trigger_kind: String,
}

impl WorkflowContext {
    /// Context for `branch_id` over `state` with a no-op recorder.
    pub fn new(branch_id: impl Into<String>, state: Arc<dyn State>) -> Self {
        Self {
            branch_id: branch_id.into(),
            config: Value::Object(Default::default()),
            instance_metadata: Value::Object(Default::default()),
            state,
            recorder: noop_recorder(),
            trigger_kind: "event".into(),
        }
    }

    /// Attach instance config.
    pub fn with_config(mut self, config: Value) -> Self {
        self.config = config;
        self
    }

    /// Attach a recorder.
    pub fn with_recorder(mut self, recorder: Arc<dyn RunRecorder>) -> Self {
        self.recorder = recorder;
        self
    }

    /// Set the trigger kind.
    pub fn with_trigger_kind(mut self, trigger_kind: impl Into<String>) -> Self {
        self.trigger_kind = trigger_kind.into();
        self
    }

    /// Prefix a state key with the branch id (R6 scoping).
    pub fn scoped(&self, key: &str) -> String {
        format!("{}.{}", self.branch_id, key)
    }
}

/// A pure transformer node. One event in, one result out.
#[async_trait]
pub trait StepNode: Send + Sync {
    /// Whether `process` may return [`StepResult::FanOut`]. Default `false`.
    fn produces_fan_out(&self) -> bool {
        false
    }

    /// Transform one event.
    async fn process(&self, event: &Event, ctx: &WorkflowContext) -> StepResult;
}