nornir 0.4.6

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Topological-sort dispatcher: "what should I work on next?"
//!
//! The single function the rest of the world (CLI, MCP, agents) calls
//! is [`topo_ready`]. It returns every node that:
//!   - belongs to a plan whose status is `Active` (or `Draft` β€”
//!     drafts are work-in-progress, not archived),
//!   - has status `Pending` or `Ready`,
//!   - has all incoming-edge dependencies satisfied (every `from`
//!     node has status `Done`),
//!
//! …in topological order so dependents always come after dependencies.
//! The returned [`NextStep`] carries everything an actor needs to
//! decide "should I take this?".

use std::collections::BTreeMap;

use petgraph::algo::toposort;
use petgraph::graph::{DiGraph, NodeIndex};

use super::event::{NodeStatus, PlanStatus};
use super::ids::{NodeId, PlanId};
use super::state::Funnel;

#[derive(Debug, Clone, serde::Serialize)]
pub struct NextStep {
    pub plan_id: PlanId,
    pub node_id: NodeId,
    pub kind: String,
    pub targets: Vec<String>,
    pub summary: String,
    pub prompt_excerpt: Option<String>,
}

/// Return every actionable node across every active plan, in
/// topological order. Side-effect: promotes eligible `Pending` nodes
/// to `Ready` in-memory (no event emitted β€” the readiness derivation
/// is deterministic from edges + statuses, so emitting events would
/// just bloat the log).
pub fn topo_ready(funnel: &mut Funnel) -> Vec<NextStep> {
    funnel.promote_ready();

    let mut out: Vec<NextStep> = Vec::new();
    for plan in funnel.plans.values() {
        if !matches!(plan.status, PlanStatus::Draft | PlanStatus::Active) {
            continue;
        }

        let mut graph: DiGraph<NodeId, ()> = DiGraph::new();
        let mut idx: BTreeMap<NodeId, NodeIndex> = BTreeMap::new();
        for id in plan.nodes.keys() {
            idx.insert(id.clone(), graph.add_node(id.clone()));
        }
        for (from, to) in &plan.edges {
            if let (Some(&a), Some(&b)) = (idx.get(from), idx.get(to)) {
                graph.add_edge(a, b, ());
            }
        }

        let order = match toposort(&graph, None) {
            Ok(o) => o,
            Err(cyc) => {
                eprintln!(
                    "funnel: plan `{}` has a cycle around node `{}` β€” skipping",
                    plan.id,
                    graph[cyc.node_id()]
                );
                continue;
            }
        };

        for ni in order {
            let nid = &graph[ni];
            let node = &plan.nodes[nid];
            if matches!(node.status, NodeStatus::Ready | NodeStatus::Pending) {
                // Only Ready is genuinely actionable; surface
                // Pending too so callers can see *why* nothing
                // ran (deps not done).
                if node.status == NodeStatus::Ready {
                    out.push(NextStep {
                        plan_id: plan.id.clone(),
                        node_id: nid.clone(),
                        kind: node.kind.clone(),
                        targets: node.targets.clone(),
                        summary: plan.summary.clone(),
                        prompt_excerpt: node.prompt_excerpt.clone(),
                    });
                }
            }
        }
    }
    out
}

/// Like [`topo_ready`] but scoped to a specific plan.
pub fn topo_ready_for_plan(funnel: &mut Funnel, plan_id: &PlanId) -> Vec<NextStep> {
    topo_ready(funnel)
        .into_iter()
        .filter(|s| &s.plan_id == plan_id)
        .collect()
}