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 /// Who is told what: for each audience (`creator`, `worker`, `dependency`, `chat`: who a card's subscriptions
244 /// are), the event kinds it hears and the template each is told in. A kind not named is not sent.
245 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246 pub notify: BTreeMap<String, BTreeMap<String, String>>,
247}
248
249/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
250/// declares none.
251pub fn hermes_instance() -> Workflow {
252 serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses")
253}
254
255/// The built-in Hermes workflow's source.
256pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
257
258impl Workflow {
259 /// Every name a transition targets or a run names must be declared; answers what is not.
260 pub fn check(&self) -> Result<(), String> {
261 let known = |t: &Option<String>| match t {
262 Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
263 Err(format!("no status {name}"))
264 }
265 _ => Ok(()),
266 };
267 let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
268 all(&self.initial)?;
269 for ts in self.on.values() {
270 all(ts)?;
271 }
272 for (name, s) in &self.statuses {
273 for ts in s.on.values() {
274 all(ts).map_err(|e| format!("{name}: {e}"))?;
275 }
276 all(&s.always).map_err(|e| format!("{name}: {e}"))?;
277 s.after
278 .iter()
279 .try_for_each(|d| known(&d.transition.target))
280 .map_err(|e| format!("{name}: {e}"))?;
281 if let Some(run) = &s.run {
282 if !self.roles.contains_key(&run.role) {
283 return Err(format!("{name}: its run names no role {}", run.role));
284 }
285 }
286 }
287 Ok(())
288 }
289}