Skip to main content

supercode_interchange/orchestration/
workflow.rs

1//! How a board is worked (docs/architecture/orchestrator.md §2.9): the whole dispatch behaviour as data, a
2//! statechart in the manner of Amazon States Language and XState. A card is always in one of the workflow's
3//! statuses; a status may run an actor for as long as the card is in it (XState's `invoke`, ASL's `Task`); events
4//! move cards between statuses through transitions that say who may send them, what must hold, and what happens
5//! (Jira's conditions, validators and post functions); the dispatcher's scheduling is data too.
6//!
7//! Nothing here is a mode or a switch: a behaviour exists because a transition, an action or an expression says
8//! so. Names (statuses, events, roles, slots, actions) are open strings; conditions, targets, limits and
9//! durations are expressions over the card and the workflow's `params`, evaluated by the board engine. Hermes's
10//! dispatcher is one instance of this model, [`hermes_instance`], used by a home that declares none.
11
12use std::collections::BTreeMap;
13
14use schemars::JsonSchema;
15use serde::de::Error as _;
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17use serde_json::{Map, Value};
18
19/// An expression over the card, its relations, the event and `params` (a small CEL-like language: literals,
20/// `card.*`, `event.*`, `params.*`, comparisons, `&&`/`||`/`!`, `in`, and the engine's functions such as
21/// `parents.all(p, …)`). Written as a string; a bare number, boolean or null is the literal it spells.
22#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
23#[schemars(transparent)]
24pub struct Expr(pub String);
25
26impl Serialize for Expr {
27    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
28        self.0.serialize(s)
29    }
30}
31
32impl<'de> Deserialize<'de> for Expr {
33    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
34        match Value::deserialize(d)? {
35            Value::String(s) => Ok(Self(s)),
36            v @ (Value::Number(_) | Value::Bool(_) | Value::Null) => Ok(Self(v.to_string())),
37            other => Err(D::Error::custom(format!(
38                "an expression is a string or a literal, not {other}"
39            ))),
40        }
41    }
42}
43
44/// One step a transition, an entry or an exit takes: a primitive the engine provides, named, with its arguments
45/// (`notify: {roles: [creator], template: …}`). Written as a one-key map.
46#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
47pub struct Action {
48    /// The primitive (`assign`, `count`, `reset`, `notify`, `comment`, `send_to_actor`, `close_session`, …).
49    pub name: String,
50    /// Its arguments, as written.
51    pub args: Value,
52}
53
54impl Serialize for Action {
55    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
56        let mut m = Map::new();
57        m.insert(self.name.clone(), self.args.clone());
58        Value::Object(m).serialize(s)
59    }
60}
61
62impl<'de> Deserialize<'de> for Action {
63    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
64        match Value::deserialize(d)? {
65            Value::String(name) => Ok(Self {
66                name,
67                args: Value::Null,
68            }),
69            Value::Object(m) if m.len() == 1 => {
70                let (name, args) = m.into_iter().next().unwrap();
71                Ok(Self { name, args })
72            }
73            other => Err(D::Error::custom(format!(
74                "an action is a name or a one-key map, not {other}"
75            ))),
76        }
77    }
78}
79
80/// A move from the card's status on an event (or on none: `always`, `after`).
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82pub struct Transition {
83    /// Who may send the event: role names (the workflow's `roles`, and the engine's `platform` and `manager`).
84    /// Empty: anyone who may write the card.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub by: Vec<String>,
87    /// The transition is taken only when this holds (Jira's condition); the first one that holds is taken.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub guard: Option<Expr>,
90    /// Must hold, else the event is refused with the message (Jira's validator).
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub validate: Vec<Validation>,
93    /// The status the card moves to: a status name, or `{{expr}}`; none stays where it is (runs the actions only).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub target: Option<String>,
96    /// What happens, in order, after the move (Jira's post functions).
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub actions: Vec<Action>,
99    /// What a person reads about it (the event's door text, the log).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102}
103
104/// A validator: the expression must hold, else the event is refused with the message.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
106pub struct Validation {
107    /// What must hold.
108    pub expr: Expr,
109    /// The refusal.
110    pub message: String,
111}
112
113/// A transition taken once a duration has passed in the status (XState's `after`, ASL's `Wait`).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct Delayed {
116    /// How long, in seconds (an expression).
117    pub after: Expr,
118    /// The move.
119    #[serde(flatten)]
120    pub transition: Transition,
121}
122
123/// What runs while a card is in a status: an actor of a role's lane in a named session slot of the card.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
125pub struct Run {
126    /// The role whose lane (the profile it names) runs it.
127    pub role: String,
128    /// The card's session slot it runs in: a live session in the slot is kept, a lost one resumed, an empty slot
129    /// started fresh. Different slots are different sessions (a reviewer never works in the implementer's).
130    pub session: String,
131    /// What the actor is told when it starts: a template over the card (`{{card.id}}`, `{{context}}`) and
132    /// `{{doors}}`, the events the actor may send in this status, generated from its transitions.
133    pub prompt: String,
134    /// Skills the actor's session is given.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub skills: Vec<String>,
137    /// Limits on the run, each an expression in seconds or a count (`runtime`, `heartbeat`, …); the engine emits
138    /// an event when one passes (`timed_out`, `stale`, …).
139    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
140    pub limits: BTreeMap<String, Expr>,
141}
142
143/// One status a card can be in.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145pub struct Status {
146    /// What `kanban.db`'s `tasks.status` holds for a card here, so Hermes reads the board (Hermes's own nine
147    /// statuses store as themselves); the exact name rides beside it where it differs.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub stored_as: Option<String>,
150    /// What a person reads about it.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub description: Option<String>,
153    /// What runs while a card is here.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub run: Option<Run>,
156    /// Done on entering (after the move's own actions).
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub entry: Vec<Action>,
159    /// Done on leaving (before the move's own actions): e.g. `close_session: reviewer`.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub exit: Vec<Action>,
162    /// Events and their transitions, the first whose guard holds taken.
163    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
164    pub on: BTreeMap<String, Vec<Transition>>,
165    /// Transitions taken as soon as their guard holds (XState's `always`).
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub always: Vec<Transition>,
168    /// Transitions taken after a time in the status.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub after: Vec<Delayed>,
171}
172
173/// A cap on what the dispatcher starts: cards counted per group, at most `max` in each.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175pub struct Limit {
176    /// What the cap is about, for its message.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub description: Option<String>,
179    /// The group a card falls in (GitHub's `concurrency.group`): `'home'`, `card.board`, `card.assignee`, …
180    pub group: Expr,
181    /// Which cards count against it.
182    pub counts: Expr,
183    /// The cap; a cap that evaluates to nothing caps nothing.
184    pub max: Expr,
185    /// Slots held back for the cards this matches while any waits (Hermes's review reservation).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub reserve: Option<Reserve>,
188}
189
190/// Slots of a limit held for some cards.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct Reserve {
193    /// The cards the slots are held for.
194    #[serde(rename = "for")]
195    pub for_cards: Expr,
196    /// How many.
197    pub slots: Expr,
198}
199
200/// The dispatcher: when it runs, what it may start, in what order, under which caps.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202pub struct Dispatch {
203    /// Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub serve: Option<Expr>,
206    /// Seconds between ticks.
207    pub tick: Expr,
208    /// The event the dispatcher sends a card it starts (to cards whose status has a transition for it).
209    pub start: String,
210    /// Which cards it may start this tick.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub eligible: Option<Expr>,
213    /// The order it starts them in: sort keys, `-` before one for descending.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub order: Vec<Expr>,
216    /// The caps.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub limits: Vec<Limit>,
219}
220
221/// A board's workflow.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223pub struct Workflow {
224    /// What a person reads about it.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub description: Option<String>,
227    /// Named values the expressions read (`params.failure_limit`); a home's config.yaml `kanban:` keys override
228    /// them by name, so Hermes's settings are this workflow's parameters.
229    #[serde(default, skip_serializing_if = "Map::is_empty")]
230    pub params: Map<String, Value>,
231    /// Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person.
232    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
233    pub roles: BTreeMap<String, Expr>,
234    /// Where a new card starts: the first transition whose guard holds.
235    pub initial: Vec<Transition>,
236    /// Every status, by name.
237    pub statuses: BTreeMap<String, Status>,
238    /// Events that apply in every status (a status's own `on` for the same event is tried first).
239    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
240    pub on: BTreeMap<String, Vec<Transition>>,
241    /// The dispatcher.
242    pub dispatch: Dispatch,
243}
244
245/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
246/// declares none.
247pub fn hermes_instance() -> Workflow {
248    serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses")
249}
250
251/// The built-in Hermes workflow's source.
252pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
253
254impl Workflow {
255    /// Every name a transition targets or a run names must be declared; answers what is not.
256    pub fn check(&self) -> Result<(), String> {
257        let known = |t: &Option<String>| match t {
258            Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
259                Err(format!("no status {name}"))
260            }
261            _ => Ok(()),
262        };
263        let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
264        all(&self.initial)?;
265        for ts in self.on.values() {
266            all(ts)?;
267        }
268        for (name, s) in &self.statuses {
269            for ts in s.on.values() {
270                all(ts).map_err(|e| format!("{name}: {e}"))?;
271            }
272            all(&s.always).map_err(|e| format!("{name}: {e}"))?;
273            s.after
274                .iter()
275                .try_for_each(|d| known(&d.transition.target))
276                .map_err(|e| format!("{name}: {e}"))?;
277            if let Some(run) = &s.run {
278                if !self.roles.contains_key(&run.role) {
279                    return Err(format!("{name}: its run names no role {}", run.role));
280                }
281            }
282        }
283        Ok(())
284    }
285}