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, in the board's one capacity language (docs/adr/0009-board-capacity.md):
187/// what it counts, in which scope, among which cards and sessions, at most how many. Every cap applies; a card starts
188/// only when each cap that matches it has room.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(deny_unknown_fields)]
191pub struct Cap {
192    /// What the cap is called where a card waits on it and in the capacity view.
193    pub name: String,
194    /// Where one count is kept: one for the whole home, or one per board, machine or profile.
195    pub scope: CapScope,
196    /// What is counted.
197    pub counts: CapCount,
198    /// Only what matches counts, and only a card that matches is held (every key that is given must match).
199    #[serde(default, rename = "where", skip_serializing_if = "CapFilter::is_empty")]
200    pub filter: CapFilter,
201    /// What matches this is neither counted nor held, whatever `where` says (a standing service that holds no slot).
202    #[serde(default, skip_serializing_if = "CapFilter::is_empty")]
203    pub unless: CapFilter,
204    /// The most there may be in each count; a max that evaluates to nothing caps nothing.
205    pub max: Expr,
206    /// Slots of each count held back for the cards this matches while any waits (Hermes's review reservation).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub reserve: Option<Reserve>,
209}
210
211/// Where a cap keeps one count.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
213#[serde(rename_all = "snake_case")]
214pub enum CapScope {
215    /// One count across every board of the home and every machine its cards run on.
216    Global,
217    /// One count per board.
218    Board,
219    /// One count per machine a card names.
220    Machine,
221    /// One count per profile (the lane a card's run is started in).
222    Profile,
223}
224
225/// What a cap counts.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum CapCount {
229    /// Cards with a live run.
230    Runs,
231    /// Cards, each once whatever its runs and sessions (a cap's `where` names the statuses that hold a slot).
232    Cards,
233    /// Live sessions the home's cards hold, in every slot (implementer and reviewer), idle or working.
234    Sessions,
235    /// Those of the sessions that are in a turn now.
236    Turns,
237    /// Cards started in this tick.
238    Starts,
239}
240
241/// Which cards and sessions a cap is about: each key given narrows it; a key's names are alternatives.
242#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(deny_unknown_fields)]
244pub struct CapFilter {
245    /// The card itself, by id.
246    #[serde(default, skip_serializing_if = "Names::is_empty")]
247    pub card: Names,
248    /// The model a session runs (the card's model, else its profile's).
249    #[serde(default, skip_serializing_if = "Names::is_empty")]
250    pub model: Names,
251    /// The harness a session runs (`claude-code`, `codex`, …).
252    #[serde(default, skip_serializing_if = "Names::is_empty")]
253    pub harness: Names,
254    /// The card's assignee (its profile).
255    #[serde(default, skip_serializing_if = "Names::is_empty")]
256    pub assignee: Names,
257    /// The card's status (for a card being started, the status its start moves it to).
258    #[serde(default, skip_serializing_if = "Names::is_empty")]
259    pub status: Names,
260    /// The status the board stores for the card (`blocked` for a paused card or one blocked on a message).
261    #[serde(default, skip_serializing_if = "Names::is_empty")]
262    pub stored: Names,
263    /// The machine the card names.
264    #[serde(default, skip_serializing_if = "Names::is_empty")]
265    pub machine: Names,
266    /// The card's board.
267    #[serde(default, skip_serializing_if = "Names::is_empty")]
268    pub board: Names,
269}
270
271impl CapFilter {
272    /// Whether it narrows nothing.
273    pub fn is_empty(&self) -> bool {
274        [
275            &self.card,
276            &self.model,
277            &self.harness,
278            &self.assignee,
279            &self.status,
280            &self.stored,
281            &self.machine,
282            &self.board,
283        ]
284        .iter()
285        .all(|n| n.is_empty())
286    }
287}
288
289/// Names written as one name or a list of them.
290#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
291#[schemars(transparent)]
292pub struct Names(pub Vec<String>);
293
294impl Names {
295    /// Whether there are none.
296    pub fn is_empty(&self) -> bool {
297        self.0.is_empty()
298    }
299}
300
301impl Serialize for Names {
302    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
303        match self.0.as_slice() {
304            [one] => one.serialize(s),
305            many => many.serialize(s),
306        }
307    }
308}
309
310impl<'de> Deserialize<'de> for Names {
311    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
312        match Value::deserialize(d)? {
313            Value::String(s) => Ok(Self(vec![s])),
314            Value::Array(items) => items
315                .into_iter()
316                .map(|v| match v {
317                    Value::String(s) => Ok(s),
318                    other => Err(D::Error::custom(format!("a name is a string, not {other}"))),
319                })
320                .collect::<Result<_, _>>()
321                .map(Self),
322            other => Err(D::Error::custom(format!(
323                "names are a string or a list of strings, not {other}"
324            ))),
325        }
326    }
327}
328
329/// Slots of a cap held for some cards.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331#[serde(deny_unknown_fields)]
332pub struct Reserve {
333    /// The cards the slots are held for.
334    #[serde(rename = "for")]
335    pub for_cards: CapFilter,
336    /// How many.
337    pub slots: Expr,
338}
339
340/// The dispatcher: when it runs, what it may start, in what order, under which caps.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342#[serde(deny_unknown_fields)]
343pub struct Dispatch {
344    /// Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`).
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub serve: Option<Expr>,
347    /// Seconds between ticks.
348    pub tick: Expr,
349    /// The event the dispatcher sends a card it starts (to cards whose status has a transition for it).
350    pub start: String,
351    /// Which cards it may start this tick.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub eligible: Option<Expr>,
354    /// The order it starts them in: sort keys, `-` before one for descending.
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub order: Vec<Expr>,
357    /// The caps, in the capacity language.
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub capacity: Vec<Cap>,
360}
361
362/// The independently supervised run machine. Its events never become card notices.
363#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364pub struct RunMachine {
365    /// Run specification selected for a card with an Every interval.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub recurring_role: Option<String>,
368    /// Shared supervision limits, overridden by individual role specifications.
369    #[serde(default)]
370    pub limits: BTreeMap<String, Expr>,
371    /// Named run specifications, selected by a card status or a recurring role.
372    #[serde(default)]
373    pub roles: BTreeMap<String, Run>,
374    /// Run states and their entry, exit and event rules.
375    #[serde(default)]
376    pub states: BTreeMap<String, Status>,
377    /// Initial run state.
378    #[serde(default)]
379    pub initial: String,
380    /// Supervision shared by all run states.
381    #[serde(default)]
382    pub on: BTreeMap<String, Vec<Transition>>,
383    /// A run outcome emits this card verb (the sole link between machines).
384    #[serde(default)]
385    pub outcomes: BTreeMap<String, Vec<Transition>>,
386}
387
388/// Ordered lowering rule for Hermes's fixed dispatcher.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
390pub struct StorageRule {
391    pub guard: Expr,
392    pub status: String,
393}
394
395/// A board's workflow.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
397pub struct Workflow {
398    /// The run machine; absent for legacy homes during migration.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub run: Option<RunMachine>,
401    /// Card verbs declared once, with source statuses on each transition.
402    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
403    pub verbs: BTreeMap<String, Vec<Transition>>,
404    /// Derived card conditions, such as ready.
405    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
406    pub derived: BTreeMap<String, Expr>,
407    /// Relation policies, interpreted by the engine's relation primitives.
408    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409    pub relations: BTreeMap<String, Value>,
410    /// Flag policies, interpreted by the engine's flag primitives.
411    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
412    pub flags: BTreeMap<String, Value>,
413    /// Ordered Hermes projection and its explicitly accepted losses.
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    pub stored_as: Vec<StorageRule>,
416    #[serde(default, skip_serializing_if = "Vec::is_empty")]
417    pub losses: Vec<String>,
418    /// What a person reads about it.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub description: Option<String>,
421    /// Named values the expressions read (`params.failure_limit`); a home's config.yaml `kanban:` keys override
422    /// them by name, so Hermes's settings are this workflow's parameters.
423    #[serde(default, skip_serializing_if = "Map::is_empty")]
424    pub params: Map<String, Value>,
425    /// Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person.
426    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
427    pub roles: BTreeMap<String, Expr>,
428    /// Where a new card starts: the first transition whose guard holds.
429    pub initial: Vec<Transition>,
430    /// Every status, by name.
431    pub statuses: BTreeMap<String, Status>,
432    /// Events that apply in every status (a status's own `on` for the same event is tried first).
433    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
434    pub on: BTreeMap<String, Vec<Transition>>,
435    /// The dispatcher.
436    pub dispatch: Dispatch,
437    /// Who is told what: for each audience (`creator`, `worker`, `dependency`, `chat`: who a card's subscriptions
438    /// are), the event kinds it hears and the template each is told in. A kind not named is not sent.
439    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
440    pub notify: BTreeMap<String, BTreeMap<String, String>>,
441}
442
443/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
444/// declares none.
445pub fn hermes_instance() -> Workflow {
446    let mut workflow: Workflow =
447        serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses");
448    if let Some(run) = &mut workflow.run {
449        for (role, prompt) in [
450            ("triager", include_str!("prompts/triager.md")),
451            ("implementer", include_str!("prompts/implementer.md")),
452            ("reviewer", include_str!("prompts/reviewer.md")),
453        ] {
454            if let Some(spec) = run.roles.get_mut(role) {
455                spec.prompt = prompt.to_owned();
456            }
457        }
458    }
459    workflow
460}
461
462/// The built-in Hermes workflow's source.
463pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
464
465impl Workflow {
466    /// Every name a transition targets or a run names must be declared; answers what is not.
467    pub fn check(&self) -> Result<(), String> {
468        let known = |t: &Option<String>| match t {
469            Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
470                Err(format!("no status {name}"))
471            }
472            _ => Ok(()),
473        };
474        let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
475        all(&self.initial)?;
476        for ts in self.verbs.values() {
477            all(ts)?;
478            for t in ts {
479                for source in &t.from {
480                    if !self.statuses.contains_key(source) {
481                        return Err(format!("no source status {source}"));
482                    }
483                }
484            }
485        }
486        if let Some(run) = &self.run {
487            if !run.states.contains_key(&run.initial) {
488                return Err(format!("no initial run state {}", run.initial));
489            }
490            for ts in run.outcomes.values() {
491                all(ts)?;
492            }
493            for ts in run
494                .on
495                .values()
496                .chain(run.states.values().flat_map(|s| s.on.values()))
497            {
498                for t in ts {
499                    if let Some(target) = &t.target {
500                        if !target.starts_with("{{") && !run.states.contains_key(target) {
501                            return Err(format!("no run state {target}"));
502                        }
503                    }
504                }
505            }
506            for (name, spec) in &run.roles {
507                if !self.roles.contains_key(&spec.role) {
508                    return Err(format!("run {name}: no role {}", spec.role));
509                }
510            }
511        }
512        for ts in self.on.values() {
513            all(ts)?;
514        }
515        for (name, s) in &self.statuses {
516            if let Some(role) = &s.run_role {
517                if !self
518                    .run
519                    .as_ref()
520                    .is_some_and(|r| r.roles.contains_key(role))
521                {
522                    return Err(format!("{name}: no run specification {role}"));
523                }
524            }
525            for ts in s.on.values() {
526                all(ts).map_err(|e| format!("{name}: {e}"))?;
527            }
528            all(&s.always).map_err(|e| format!("{name}: {e}"))?;
529            s.after
530                .iter()
531                .try_for_each(|d| known(&d.transition.target))
532                .map_err(|e| format!("{name}: {e}"))?;
533            if let Some(run) = &s.run {
534                if !self.roles.contains_key(&run.role) {
535                    return Err(format!("{name}: its run names no role {}", run.role));
536                }
537            }
538        }
539        Ok(())
540    }
541}