supercode-interchange 0.5.51

Canonical, provider-neutral session interchange primitives for Volter Harness
Documentation
//! How a board is worked (docs/architecture/orchestrator.md §2.9): the whole dispatch behaviour as data, a
//! statechart in the manner of Amazon States Language and XState. A card is always in one of the workflow's
//! statuses; a status may run an actor for as long as the card is in it (XState's `invoke`, ASL's `Task`); events
//! move cards between statuses through transitions that say who may send them, what must hold, and what happens
//! (Jira's conditions, validators and post functions); the dispatcher's scheduling is data too.
//!
//! Nothing here is a mode or a switch: a behaviour exists because a transition, an action or an expression says
//! so. Names (statuses, events, roles, slots, actions) are open strings; conditions, targets, limits and
//! durations are expressions over the card and the workflow's `params`, evaluated by the board engine. Hermes's
//! dispatcher is one instance of this model, [`hermes_instance`], used by a home that declares none.

use std::collections::BTreeMap;

use schemars::JsonSchema;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};

/// An expression over the card, its relations, the event and `params` (a small CEL-like language: literals,
/// `card.*`, `event.*`, `params.*`, comparisons, `&&`/`||`/`!`, `in`, and the engine's functions such as
/// `parents.all(p, …)`). Written as a string; a bare number, boolean or null is the literal it spells.
#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(transparent)]
pub struct Expr(pub String);

impl Serialize for Expr {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        self.0.serialize(s)
    }
}

impl<'de> Deserialize<'de> for Expr {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        match Value::deserialize(d)? {
            Value::String(s) => Ok(Self(s)),
            v @ (Value::Number(_) | Value::Bool(_) | Value::Null) => Ok(Self(v.to_string())),
            other => Err(D::Error::custom(format!(
                "an expression is a string or a literal, not {other}"
            ))),
        }
    }
}

/// One step a transition, an entry or an exit takes: a primitive the engine provides, named, with its arguments
/// (`notify: {roles: [creator], template: …}`). Written as a one-key map.
#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
pub struct Action {
    /// The primitive (`assign`, `count`, `reset`, `notify`, `comment`, `send_to_actor`, `close_session`, …).
    pub name: String,
    /// Its arguments, as written.
    pub args: Value,
}

impl Serialize for Action {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        let mut m = Map::new();
        m.insert(self.name.clone(), self.args.clone());
        Value::Object(m).serialize(s)
    }
}

impl<'de> Deserialize<'de> for Action {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        match Value::deserialize(d)? {
            Value::String(name) => Ok(Self {
                name,
                args: Value::Null,
            }),
            Value::Object(m) if m.len() == 1 => {
                let (name, args) = m.into_iter().next().unwrap();
                Ok(Self { name, args })
            }
            other => Err(D::Error::custom(format!(
                "an action is a name or a one-key map, not {other}"
            ))),
        }
    }
}

/// A move from the card's status on an event (or on none: `always`, `after`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Transition {
    /// Who may send the event: role names (the workflow's `roles`, and the engine's `platform` and `manager`).
    /// Empty: anyone who may write the card.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub by: Vec<String>,
    /// The transition is taken only when this holds (Jira's condition); the first one that holds is taken.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub guard: Option<Expr>,
    /// Must hold, else the event is refused with the message (Jira's validator).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub validate: Vec<Validation>,
    /// The status the card moves to: a status name, or `{{expr}}`; none stays where it is (runs the actions only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    /// What happens, in order, after the move (Jira's post functions).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub actions: Vec<Action>,
    /// What a person reads about it (the event's door text, the log).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// A validator: the expression must hold, else the event is refused with the message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Validation {
    /// What must hold.
    pub expr: Expr,
    /// The refusal.
    pub message: String,
}

/// A transition taken once a duration has passed in the status (XState's `after`, ASL's `Wait`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Delayed {
    /// How long, in seconds (an expression).
    pub after: Expr,
    /// The move.
    #[serde(flatten)]
    pub transition: Transition,
}

/// What runs while a card is in a status: an actor of a role's lane in a named session slot of the card.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Run {
    /// The role whose lane (the profile it names) runs it.
    pub role: String,
    /// The card's session slot it runs in: a live session in the slot is kept, a lost one resumed, an empty slot
    /// started fresh. Different slots are different sessions (a reviewer never works in the implementer's).
    pub session: String,
    /// What the actor is told when it starts: a template over the card (`{{card.id}}`, `{{context}}`) and
    /// `{{doors}}`, the events the actor may send in this status, generated from its transitions.
    pub prompt: String,
    /// Skills the actor's session is given.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,
    /// Limits on the run, each an expression in seconds or a count (`runtime`, `heartbeat`, …); the engine emits
    /// an event when one passes (`timed_out`, `stale`, …).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub limits: BTreeMap<String, Expr>,
}

/// One status a card can be in.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Status {
    /// What `kanban.db`'s `tasks.status` holds for a card here, so Hermes reads the board (Hermes's own nine
    /// statuses store as themselves); the exact name rides beside it where it differs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stored_as: Option<String>,
    /// What a person reads about it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// What runs while a card is here.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run: Option<Run>,
    /// Done on entering (after the move's own actions).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entry: Vec<Action>,
    /// Done on leaving (before the move's own actions): e.g. `close_session: reviewer`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub exit: Vec<Action>,
    /// Events and their transitions, the first whose guard holds taken.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub on: BTreeMap<String, Vec<Transition>>,
    /// Transitions taken as soon as their guard holds (XState's `always`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub always: Vec<Transition>,
    /// Transitions taken after a time in the status.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub after: Vec<Delayed>,
}

/// A cap on what the dispatcher starts: cards counted per group, at most `max` in each.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Limit {
    /// What the cap is about, for its message.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The group a card falls in (GitHub's `concurrency.group`): `'home'`, `card.board`, `card.assignee`, …
    pub group: Expr,
    /// Which cards count against it.
    pub counts: Expr,
    /// The cap; a cap that evaluates to nothing caps nothing.
    pub max: Expr,
    /// Slots held back for the cards this matches while any waits (Hermes's review reservation).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reserve: Option<Reserve>,
}

/// Slots of a limit held for some cards.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Reserve {
    /// The cards the slots are held for.
    #[serde(rename = "for")]
    pub for_cards: Expr,
    /// How many.
    pub slots: Expr,
}

/// The dispatcher: when it runs, what it may start, in what order, under which caps.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Dispatch {
    /// Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub serve: Option<Expr>,
    /// Seconds between ticks.
    pub tick: Expr,
    /// The event the dispatcher sends a card it starts (to cards whose status has a transition for it).
    pub start: String,
    /// Which cards it may start this tick.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub eligible: Option<Expr>,
    /// The order it starts them in: sort keys, `-` before one for descending.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub order: Vec<Expr>,
    /// The caps.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub limits: Vec<Limit>,
}

/// A board's workflow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Workflow {
    /// What a person reads about it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Named values the expressions read (`params.failure_limit`); a home's config.yaml `kanban:` keys override
    /// them by name, so Hermes's settings are this workflow's parameters.
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    pub params: Map<String, Value>,
    /// Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub roles: BTreeMap<String, Expr>,
    /// Where a new card starts: the first transition whose guard holds.
    pub initial: Vec<Transition>,
    /// Every status, by name.
    pub statuses: BTreeMap<String, Status>,
    /// Events that apply in every status (a status's own `on` for the same event is tried first).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub on: BTreeMap<String, Vec<Transition>>,
    /// The dispatcher.
    pub dispatch: Dispatch,
}

/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
/// declares none.
pub fn hermes_instance() -> Workflow {
    serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses")
}

/// The built-in Hermes workflow's source.
pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");

impl Workflow {
    /// Every name a transition targets or a run names must be declared; answers what is not.
    pub fn check(&self) -> Result<(), String> {
        let known = |t: &Option<String>| match t {
            Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
                Err(format!("no status {name}"))
            }
            _ => Ok(()),
        };
        let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
        all(&self.initial)?;
        for ts in self.on.values() {
            all(ts)?;
        }
        for (name, s) in &self.statuses {
            for ts in s.on.values() {
                all(ts).map_err(|e| format!("{name}: {e}"))?;
            }
            all(&s.always).map_err(|e| format!("{name}: {e}"))?;
            s.after
                .iter()
                .try_for_each(|d| known(&d.transition.target))
                .map_err(|e| format!("{name}: {e}"))?;
            if let Some(run) = &s.run {
                if !self.roles.contains_key(&run.role) {
                    return Err(format!("{name}: its run names no role {}", run.role));
                }
            }
        }
        Ok(())
    }
}