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 machine the card names.
261    #[serde(default, skip_serializing_if = "Names::is_empty")]
262    pub machine: Names,
263    /// The card's board.
264    #[serde(default, skip_serializing_if = "Names::is_empty")]
265    pub board: Names,
266}
267
268impl CapFilter {
269    /// Whether it narrows nothing.
270    pub fn is_empty(&self) -> bool {
271        [
272            &self.card,
273            &self.model,
274            &self.harness,
275            &self.assignee,
276            &self.status,
277            &self.machine,
278            &self.board,
279        ]
280        .iter()
281        .all(|n| n.is_empty())
282    }
283}
284
285/// Names written as one name or a list of them.
286#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
287#[schemars(transparent)]
288pub struct Names(pub Vec<String>);
289
290impl Names {
291    /// Whether there are none.
292    pub fn is_empty(&self) -> bool {
293        self.0.is_empty()
294    }
295}
296
297impl Serialize for Names {
298    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
299        match self.0.as_slice() {
300            [one] => one.serialize(s),
301            many => many.serialize(s),
302        }
303    }
304}
305
306impl<'de> Deserialize<'de> for Names {
307    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
308        match Value::deserialize(d)? {
309            Value::String(s) => Ok(Self(vec![s])),
310            Value::Array(items) => items
311                .into_iter()
312                .map(|v| match v {
313                    Value::String(s) => Ok(s),
314                    other => Err(D::Error::custom(format!("a name is a string, not {other}"))),
315                })
316                .collect::<Result<_, _>>()
317                .map(Self),
318            other => Err(D::Error::custom(format!(
319                "names are a string or a list of strings, not {other}"
320            ))),
321        }
322    }
323}
324
325/// Slots of a cap held for some cards.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
327#[serde(deny_unknown_fields)]
328pub struct Reserve {
329    /// The cards the slots are held for.
330    #[serde(rename = "for")]
331    pub for_cards: CapFilter,
332    /// How many.
333    pub slots: Expr,
334}
335
336/// The dispatcher: when it runs, what it may start, in what order, under which caps.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338#[serde(deny_unknown_fields)]
339pub struct Dispatch {
340    /// Whether the served home dispatches its boards (an expression; Hermes's `dispatch_in_gateway`).
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub serve: Option<Expr>,
343    /// Seconds between ticks.
344    pub tick: Expr,
345    /// The event the dispatcher sends a card it starts (to cards whose status has a transition for it).
346    pub start: String,
347    /// Which cards it may start this tick.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub eligible: Option<Expr>,
350    /// The order it starts them in: sort keys, `-` before one for descending.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub order: Vec<Expr>,
353    /// The caps, in the capacity language.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub capacity: Vec<Cap>,
356}
357
358/// The independently supervised run machine. Its events never become card notices.
359#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
360pub struct RunMachine {
361    /// Run specification selected for a card with an Every interval.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub recurring_role: Option<String>,
364    /// Shared supervision limits, overridden by individual role specifications.
365    #[serde(default)]
366    pub limits: BTreeMap<String, Expr>,
367    /// Named run specifications, selected by a card status or a recurring role.
368    #[serde(default)]
369    pub roles: BTreeMap<String, Run>,
370    /// Run states and their entry, exit and event rules.
371    #[serde(default)]
372    pub states: BTreeMap<String, Status>,
373    /// Initial run state.
374    #[serde(default)]
375    pub initial: String,
376    /// Supervision shared by all run states.
377    #[serde(default)]
378    pub on: BTreeMap<String, Vec<Transition>>,
379    /// A run outcome emits this card verb (the sole link between machines).
380    #[serde(default)]
381    pub outcomes: BTreeMap<String, Vec<Transition>>,
382}
383
384/// Ordered lowering rule for Hermes's fixed dispatcher.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
386pub struct StorageRule {
387    pub guard: Expr,
388    pub status: String,
389}
390
391/// A board's workflow.
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
393pub struct Workflow {
394    /// The run machine; absent for legacy homes during migration.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub run: Option<RunMachine>,
397    /// Card verbs declared once, with source statuses on each transition.
398    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
399    pub verbs: BTreeMap<String, Vec<Transition>>,
400    /// Derived card conditions, such as ready.
401    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
402    pub derived: BTreeMap<String, Expr>,
403    /// Relation policies, interpreted by the engine's relation primitives.
404    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
405    pub relations: BTreeMap<String, Value>,
406    /// Flag policies, interpreted by the engine's flag primitives.
407    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
408    pub flags: BTreeMap<String, Value>,
409    /// Ordered Hermes projection and its explicitly accepted losses.
410    #[serde(default, skip_serializing_if = "Vec::is_empty")]
411    pub stored_as: Vec<StorageRule>,
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub losses: Vec<String>,
414    /// What a person reads about it.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub description: Option<String>,
417    /// Named values the expressions read (`params.failure_limit`); a home's config.yaml `kanban:` keys override
418    /// them by name, so Hermes's settings are this workflow's parameters.
419    #[serde(default, skip_serializing_if = "Map::is_empty")]
420    pub params: Map<String, Value>,
421    /// Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person.
422    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
423    pub roles: BTreeMap<String, Expr>,
424    /// Where a new card starts: the first transition whose guard holds.
425    pub initial: Vec<Transition>,
426    /// Every status, by name.
427    pub statuses: BTreeMap<String, Status>,
428    /// Events that apply in every status (a status's own `on` for the same event is tried first).
429    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
430    pub on: BTreeMap<String, Vec<Transition>>,
431    /// The dispatcher.
432    pub dispatch: Dispatch,
433    /// Who is told what: for each audience (`creator`, `worker`, `dependency`, `chat`: who a card's subscriptions
434    /// are), the event kinds it hears and the template each is told in. A kind not named is not sent.
435    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
436    pub notify: BTreeMap<String, BTreeMap<String, String>>,
437}
438
439/// Hermes's dispatcher, transcribed as data (`hermes_cli/kanban_db.py`): the workflow a home runs when it
440/// declares none.
441pub fn hermes_instance() -> Workflow {
442    let mut workflow: Workflow =
443        serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses");
444    if let Some(run) = &mut workflow.run {
445        for (role, prompt) in [
446            ("triager", include_str!("prompts/triager.md")),
447            ("implementer", include_str!("prompts/implementer.md")),
448            ("reviewer", include_str!("prompts/reviewer.md")),
449        ] {
450            if let Some(spec) = run.roles.get_mut(role) {
451                spec.prompt = prompt.to_owned();
452            }
453        }
454    }
455    workflow
456}
457
458/// The built-in Hermes workflow's source.
459pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
460
461impl Workflow {
462    /// Every name a transition targets or a run names must be declared; answers what is not.
463    pub fn check(&self) -> Result<(), String> {
464        let known = |t: &Option<String>| match t {
465            Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
466                Err(format!("no status {name}"))
467            }
468            _ => Ok(()),
469        };
470        let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
471        all(&self.initial)?;
472        for ts in self.verbs.values() {
473            all(ts)?;
474            for t in ts {
475                for source in &t.from {
476                    if !self.statuses.contains_key(source) {
477                        return Err(format!("no source status {source}"));
478                    }
479                }
480            }
481        }
482        if let Some(run) = &self.run {
483            if !run.states.contains_key(&run.initial) {
484                return Err(format!("no initial run state {}", run.initial));
485            }
486            for ts in run.outcomes.values() {
487                all(ts)?;
488            }
489            for ts in run
490                .on
491                .values()
492                .chain(run.states.values().flat_map(|s| s.on.values()))
493            {
494                for t in ts {
495                    if let Some(target) = &t.target {
496                        if !target.starts_with("{{") && !run.states.contains_key(target) {
497                            return Err(format!("no run state {target}"));
498                        }
499                    }
500                }
501            }
502            for (name, spec) in &run.roles {
503                if !self.roles.contains_key(&spec.role) {
504                    return Err(format!("run {name}: no role {}", spec.role));
505                }
506            }
507        }
508        for ts in self.on.values() {
509            all(ts)?;
510        }
511        for (name, s) in &self.statuses {
512            if let Some(role) = &s.run_role {
513                if !self
514                    .run
515                    .as_ref()
516                    .is_some_and(|r| r.roles.contains_key(role))
517                {
518                    return Err(format!("{name}: no run specification {role}"));
519                }
520            }
521            for ts in s.on.values() {
522                all(ts).map_err(|e| format!("{name}: {e}"))?;
523            }
524            all(&s.always).map_err(|e| format!("{name}: {e}"))?;
525            s.after
526                .iter()
527                .try_for_each(|d| known(&d.transition.target))
528                .map_err(|e| format!("{name}: {e}"))?;
529            if let Some(run) = &s.run {
530                if !self.roles.contains_key(&run.role) {
531                    return Err(format!("{name}: its run names no role {}", run.role));
532                }
533            }
534        }
535        Ok(())
536    }
537}