nornir 0.4.32

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Append-only event log entries that materialise the funnel state.
//!
//! Every mutation to the funnel — submitting an idea, adding a plan
//! node, flipping a status, recording a run — is serialised as a
//! single [`Event`] appended as a row to the Iceberg `funnel_events` table
//! under `<workspace>/.nornir/warehouse` (one snapshot per write).
//! Loading the funnel = replaying every row into [`super::state::Funnel`].
//!
//! Events are versioned implicitly by serde's `#[serde(tag = "kind")]`
//! discriminator. New event kinds can be added at the bottom; old rows
//! keep replaying cleanly as long as renames go through `#[serde(rename)]`.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::ids::{IdeaId, NodeId, PlanId, RunId};

/// Where an idea originated. Free-form so MCP / CI integrations can
/// add their own source tags without a schema bump.
pub type Source = String;

/// File-glob style target marker. Empty means "no target".
pub type Target = String;

/// Lifecycle of a plan node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeStatus {
    /// Created but deps not yet satisfied.
    Pending,
    /// All deps done; eligible for `topo_ready()`.
    Ready,
    /// Someone (human or agent) picked it up.
    InProgress,
    /// Successfully completed; dependents may now be `Ready`.
    Done,
    /// Cannot proceed; dependents remain `Pending` forever unless
    /// re-planned with an alternate route.
    Blocked,
    /// Attempted and failed; can be re-attempted (a new `Run` will
    /// be recorded — node stays `Failed` until explicitly flipped).
    Failed,
}

/// Lifecycle of a plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanStatus {
    Draft,
    Active,
    Done,
    Abandoned,
}

/// What kind of intake a funnel item is. An **idea** is a feature/krav/prompt;
/// an **error** is a pasted error report / bug; a **test** is a test-idea (a
/// missing/desired test). All three are first-class funnel items (EPIC
/// FUNNEL-INTAKE / FI3 + the funnel-planning extension): each triages → a plan
/// node exactly the same way, only the `node_kind` the auto-planner stamps
/// differs (feature / error-fix / test). Serialised lower-case
/// (`idea` / `error` / `test`) into the `funnel_events` `item_kind` column.
/// `Idea` is the default so every pre-FI row (no column / null) reads back as
/// an idea.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
    /// A feature request / krav / prompt — the original funnel intake.
    #[default]
    Idea,
    /// A pasted error report / bug — captured the same way, triageable to a plan.
    Error,
    /// A test-idea — a desired/missing test. Planned into test node(s) on the
    /// affected component(s), exactly like an idea/error (the auto-planner just
    /// stamps a `test:*` node kind).
    Test,
}

impl ItemKind {
    /// The lower-case wire/CLI string (`idea` | `error` | `test`).
    pub fn as_str(&self) -> &'static str {
        match self {
            ItemKind::Idea => "idea",
            ItemKind::Error => "error",
            ItemKind::Test => "test",
        }
    }

    /// Parse the wire/CLI string; unknown / empty → `Idea` (the safe default so
    /// legacy rows with no `item_kind` replay as ideas).
    pub fn parse(s: &str) -> Self {
        match s {
            "error" => ItemKind::Error,
            "test" => ItemKind::Test,
            _ => ItemKind::Idea,
        }
    }

    /// The `node_kind` the auto-planner stamps on a plan node derived from an
    /// item of this kind: a feature item → `code:write`, an error → `code:fix`,
    /// a test-idea → `test:write`. (Free verbs, matching the existing CLI
    /// convention — see `nornir funnel node`.)
    pub fn node_kind(&self) -> &'static str {
        match self {
            ItemKind::Idea => "code:write",
            ItemKind::Error => "code:fix",
            ItemKind::Test => "test:write",
        }
    }
}

/// Triage decision on an incoming idea.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriageDecision {
    Accept,
    Drop,
}

/// Outcome of a single execution attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
    Ok,
    Failed,
    Aborted,
}

/// Discriminated union of every mutation the funnel understands.
///
/// **Append-only.** Once a variant ships, it must keep deserialising
/// from older logs. Add new variants at the bottom; never remove or
/// reorder existing ones.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Event {
    IdeaSubmitted {
        id: IdeaId,
        source: Source,
        text: String,
        #[serde(default)]
        refs: Vec<String>,
        /// `idea` (default) or `error` — an error report is a first-class funnel
        /// item triageable to a plan like any idea. `serde` default keeps
        /// pre-FI logs (no field) replaying as ideas.
        #[serde(default)]
        item_kind: ItemKind,
        ts: DateTime<Utc>,
    },
    IdeaTriaged {
        idea_id: IdeaId,
        decision: TriageDecision,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    PlanCreated {
        id: PlanId,
        idea_id: IdeaId,
        summary: String,
        planner: String,
        ts: DateTime<Utc>,
    },
    NodeAdded {
        plan_id: PlanId,
        node_id: NodeId,
        #[serde(rename = "node_kind")]
        kind: String,
        #[serde(default)]
        params: serde_json::Map<String, serde_json::Value>,
        #[serde(default)]
        targets: Vec<Target>,
        #[serde(default)]
        prompt_excerpt: Option<String>,
        ts: DateTime<Utc>,
    },
    EdgeAdded {
        plan_id: PlanId,
        from_node: NodeId,
        to_node: NodeId,
        ts: DateTime<Utc>,
    },
    NodeStatusChanged {
        plan_id: PlanId,
        node_id: NodeId,
        status: NodeStatus,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
    RunRecorded {
        plan_id: PlanId,
        node_id: NodeId,
        run_id: RunId,
        ran_by: String,
        outcome: RunOutcome,
        #[serde(default)]
        log_ref: Option<String>,
        #[serde(default)]
        produced_commits: Vec<CommitRef>,
        #[serde(default)]
        produced_test_runs: Vec<String>,
        ts: DateTime<Utc>,
    },
    PlanStatusChanged {
        plan_id: PlanId,
        status: PlanStatus,
        #[serde(default)]
        why: Option<String>,
        ts: DateTime<Utc>,
    },
}

/// Identifies a commit a [`RunRecorded`] event produced.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitRef {
    pub repo: String,
    pub sha: String,
}

impl Event {
    /// Timestamp this event was produced at. Used by the store for
    /// ordering when external tools replay the log out of order.
    pub fn ts(&self) -> DateTime<Utc> {
        match self {
            Event::IdeaSubmitted { ts, .. }
            | Event::IdeaTriaged { ts, .. }
            | Event::PlanCreated { ts, .. }
            | Event::NodeAdded { ts, .. }
            | Event::EdgeAdded { ts, .. }
            | Event::NodeStatusChanged { ts, .. }
            | Event::RunRecorded { ts, .. }
            | Event::PlanStatusChanged { ts, .. } => *ts,
        }
    }
}