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    /// Source statuses; empty applies from every status.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub from: Vec<String>,
86    /// Who may send the event: role names (the workflow's `roles`, and the engine's `platform` and `manager`).
87    /// Empty: anyone who may write the card.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub by: Vec<String>,
90    /// The transition is taken only when this holds (Jira's condition); the first one that holds is taken.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub guard: Option<Expr>,
93    /// Must hold, else the event is refused with the message (Jira's validator).
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub validate: Vec<Validation>,
96    /// The status the card moves to: a status name, or `{{expr}}`; none stays where it is (runs the actions only).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub target: Option<String>,
99    /// What happens, in order, after the move (Jira's post functions).
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub actions: Vec<Action>,
102    /// What a person reads about it (the event's door text, the log).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub description: Option<String>,
105}
106
107/// A validator: the expression must hold, else the event is refused with the message.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
109pub struct Validation {
110    /// What must hold.
111    pub expr: Expr,
112    /// The refusal.
113    pub message: String,
114}
115
116/// A transition taken once a duration has passed in the status (XState's `after`, ASL's `Wait`).
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
118pub struct Delayed {
119    /// How long, in seconds (an expression).
120    pub after: Expr,
121    /// The move.
122    #[serde(flatten)]
123    pub transition: Transition,
124}
125
126/// What runs while a card is in a status: an actor of a role's lane in a named session slot of the card.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128pub struct Run {
129    /// The role whose lane (the profile it names) runs it.
130    pub role: String,
131    /// The card's session slot it runs in: a live session in the slot is kept, a lost one resumed, an empty slot
132    /// started fresh. Different slots are different sessions (a reviewer never works in the implementer's).
133    pub session: String,
134    /// What the actor is told when it starts: a template over the card (`{{card.id}}`, `{{context}}`) and
135    /// `{{doors}}`, the events the actor may send in this status, generated from its transitions.
136    #[serde(default)]
137    pub prompt: String,
138    /// Prompt file relative to the orchestration home.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub prompt_file: Option<String>,
141    /// Skills the actor's session is given.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub skills: Vec<String>,
144    /// Limits on the run, each an expression in seconds or a count (`runtime`, `heartbeat`, …); the engine emits
145    /// an event when one passes (`timed_out`, `stale`, …).
146    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
147    pub limits: BTreeMap<String, Expr>,
148}
149
150/// One status a card can be in.
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152pub struct Status {
153    /// What `kanban.db`'s `tasks.status` holds for a card here, so Hermes reads the board (Hermes's own nine
154    /// statuses store as themselves); the exact name rides beside it where it differs.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub stored_as: Option<String>,
157    /// What a person reads about it.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub description: Option<String>,
160    /// What runs while a card is here.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub run: Option<Run>,
163    /// A specification in the run machine's roles, without repeating supervision.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub run_role: Option<String>,
166    /// Status used when a failed or blocked run is later resumed.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub resume_as: Option<String>,
169    /// Done on entering (after the move's own actions).
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub entry: Vec<Action>,
172    /// Done on leaving (before the move's own actions): e.g. `close_session: reviewer`.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub exit: Vec<Action>,
175    /// Events and their transitions, the first whose guard holds taken.
176    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177    pub on: BTreeMap<String, Vec<Transition>>,
178    /// Transitions taken as soon as their guard holds (XState's `always`).
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub always: Vec<Transition>,
181    /// Transitions taken after a time in the status.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub after: Vec<Delayed>,
184}
185
186/// A cap on what the dispatcher starts: cards counted per group, at most `max` in each.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188pub struct Limit {
189    /// What the cap is about, for its message.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub description: Option<String>,
192    /// The group a card falls in (GitHub's `concurrency.group`): `'home'`, `card.board`, `card.assignee`, …
193    pub group: Expr,
194    /// Which cards count against it.
195    pub counts: Expr,
196    /// The cap; a cap that evaluates to nothing caps nothing.
197    pub max: Expr,
198    /// Slots held back for the cards this matches while any waits (Hermes's review reservation).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub reserve: Option<Reserve>,
201}
202
203/// Slots of a limit held for some cards.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205pub struct Reserve {
206    /// The cards the slots are held for.
207    #[serde(rename = "for")]
208    pub for_cards: Expr,
209    /// How many.
210    pub slots: Expr,
211}
212
213/// The dispatcher: when it runs, what it may start, in what order, under which caps.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
215pub struct Dispatch {
216    /// Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub serve: Option<Expr>,
219    /// Seconds between ticks.
220    pub tick: Expr,
221    /// The event the dispatcher sends a card it starts (to cards whose status has a transition for it).
222    pub start: String,
223    /// Which cards it may start this tick.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub eligible: Option<Expr>,
226    /// The order it starts them in: sort keys, `-` before one for descending.
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub order: Vec<Expr>,
229    /// The caps.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub limits: Vec<Limit>,
232}
233
234/// The independently supervised run machine. Its events never become card notices.
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct RunMachine {
237    /// Run specification selected for a card with an Every interval.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub recurring_role: Option<String>,
240    /// Shared supervision limits, overridden by individual role specifications.
241    #[serde(default)]
242    pub limits: BTreeMap<String, Expr>,
243    /// Named run specifications, selected by a card status or a recurring role.
244    #[serde(default)]
245    pub roles: BTreeMap<String, Run>,
246    /// Run states and their entry, exit and event rules.
247    #[serde(default)]
248    pub states: BTreeMap<String, Status>,
249    /// Initial run state.
250    #[serde(default)]
251    pub initial: String,
252    /// Supervision shared by all run states.
253    #[serde(default)]
254    pub on: BTreeMap<String, Vec<Transition>>,
255    /// A run outcome emits this card verb (the sole link between machines).
256    #[serde(default)]
257    pub outcomes: BTreeMap<String, Vec<Transition>>,
258}
259
260/// Ordered lowering rule for Hermes's fixed dispatcher.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262pub struct StorageRule {
263    pub guard: Expr,
264    pub status: String,
265}
266
267/// A board's workflow.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
269pub struct Workflow {
270    /// The run machine; absent for legacy homes during migration.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub run: Option<RunMachine>,
273    /// Card verbs declared once, with source statuses on each transition.
274    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275    pub verbs: BTreeMap<String, Vec<Transition>>,
276    /// Derived card conditions, such as ready.
277    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278    pub derived: BTreeMap<String, Expr>,
279    /// Relation policies, interpreted by the engine's relation primitives.
280    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
281    pub relations: BTreeMap<String, Value>,
282    /// Flag policies, interpreted by the engine's flag primitives.
283    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
284    pub flags: BTreeMap<String, Value>,
285    /// Ordered Hermes projection and its explicitly accepted losses.
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub stored_as: Vec<StorageRule>,
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub losses: Vec<String>,
290    /// What a person reads about it.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub description: Option<String>,
293    /// Named values the expressions read (`params.failure_limit`); a home's config.yaml `kanban:` keys override
294    /// them by name, so Hermes's settings are this workflow's parameters.
295    #[serde(default, skip_serializing_if = "Map::is_empty")]
296    pub params: Map<String, Value>,
297    /// Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person.
298    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
299    pub roles: BTreeMap<String, Expr>,
300    /// Where a new card starts: the first transition whose guard holds.
301    pub initial: Vec<Transition>,
302    /// Every status, by name.
303    pub statuses: BTreeMap<String, Status>,
304    /// Events that apply in every status (a status's own `on` for the same event is tried first).
305    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
306    pub on: BTreeMap<String, Vec<Transition>>,
307    /// The dispatcher.
308    pub dispatch: Dispatch,
309    /// Who is told what: for each audience (`creator`, `worker`, `dependency`, `chat`: who a card's subscriptions
310    /// are), the event kinds it hears and the template each is told in. A kind not named is not sent.
311    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
312    pub notify: BTreeMap<String, BTreeMap<String, String>>,
313}
314
315/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
316/// declares none.
317pub fn hermes_instance() -> Workflow {
318    let mut workflow: Workflow =
319        serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses");
320    if let Some(run) = &mut workflow.run {
321        for (role, prompt) in [
322            ("triager", include_str!("prompts/triager.md")),
323            ("implementer", include_str!("prompts/implementer.md")),
324            ("reviewer", include_str!("prompts/reviewer.md")),
325        ] {
326            if let Some(spec) = run.roles.get_mut(role) {
327                spec.prompt = prompt.to_owned();
328            }
329        }
330    }
331    workflow
332}
333
334/// The built-in Hermes workflow's source.
335pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
336
337impl Workflow {
338    /// Every name a transition targets or a run names must be declared; answers what is not.
339    pub fn check(&self) -> Result<(), String> {
340        let known = |t: &Option<String>| match t {
341            Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
342                Err(format!("no status {name}"))
343            }
344            _ => Ok(()),
345        };
346        let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
347        all(&self.initial)?;
348        for ts in self.verbs.values() {
349            all(ts)?;
350            for t in ts {
351                for source in &t.from {
352                    if !self.statuses.contains_key(source) {
353                        return Err(format!("no source status {source}"));
354                    }
355                }
356            }
357        }
358        if let Some(run) = &self.run {
359            if !run.states.contains_key(&run.initial) {
360                return Err(format!("no initial run state {}", run.initial));
361            }
362            for ts in run.outcomes.values() {
363                all(ts)?;
364            }
365            for ts in run
366                .on
367                .values()
368                .chain(run.states.values().flat_map(|s| s.on.values()))
369            {
370                for t in ts {
371                    if let Some(target) = &t.target {
372                        if !target.starts_with("{{") && !run.states.contains_key(target) {
373                            return Err(format!("no run state {target}"));
374                        }
375                    }
376                }
377            }
378            for (name, spec) in &run.roles {
379                if !self.roles.contains_key(&spec.role) {
380                    return Err(format!("run {name}: no role {}", spec.role));
381                }
382            }
383        }
384        for ts in self.on.values() {
385            all(ts)?;
386        }
387        for (name, s) in &self.statuses {
388            if let Some(role) = &s.run_role {
389                if !self
390                    .run
391                    .as_ref()
392                    .is_some_and(|r| r.roles.contains_key(role))
393                {
394                    return Err(format!("{name}: no run specification {role}"));
395                }
396            }
397            for ts in s.on.values() {
398                all(ts).map_err(|e| format!("{name}: {e}"))?;
399            }
400            all(&s.always).map_err(|e| format!("{name}: {e}"))?;
401            s.after
402                .iter()
403                .try_for_each(|d| known(&d.transition.target))
404                .map_err(|e| format!("{name}: {e}"))?;
405            if let Some(run) = &s.run {
406                if !self.roles.contains_key(&run.role) {
407                    return Err(format!("{name}: its run names no role {}", run.role));
408                }
409            }
410        }
411        Ok(())
412    }
413}