1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
47pub struct Action {
48 pub name: String,
50 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82pub struct Transition {
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub from: Vec<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub by: Vec<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub guard: Option<Expr>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub validate: Vec<Validation>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub target: Option<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub actions: Vec<Action>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub description: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
109pub struct Validation {
110 pub expr: Expr,
112 pub message: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
118pub struct Delayed {
119 pub after: Expr,
121 #[serde(flatten)]
123 pub transition: Transition,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128pub struct Run {
129 pub role: String,
131 pub session: String,
134 #[serde(default)]
137 pub prompt: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub prompt_file: Option<String>,
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub skills: Vec<String>,
144 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
147 pub limits: BTreeMap<String, Expr>,
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
152pub struct Status {
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub stored_as: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub description: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub run: Option<Run>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub run_role: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub resume_as: Option<String>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub entry: Vec<Action>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub exit: Vec<Action>,
175 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177 pub on: BTreeMap<String, Vec<Transition>>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub always: Vec<Transition>,
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub after: Vec<Delayed>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(deny_unknown_fields)]
191pub struct Cap {
192 pub name: String,
194 pub scope: CapScope,
196 pub counts: CapCount,
198 #[serde(default, rename = "where", skip_serializing_if = "CapFilter::is_empty")]
200 pub filter: CapFilter,
201 #[serde(default, skip_serializing_if = "CapFilter::is_empty")]
203 pub unless: CapFilter,
204 pub max: Expr,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub reserve: Option<Reserve>,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
213#[serde(rename_all = "snake_case")]
214pub enum CapScope {
215 Global,
217 Board,
219 Machine,
221 Profile,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum CapCount {
229 Runs,
231 Cards,
233 Sessions,
235 Turns,
237 Starts,
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(deny_unknown_fields)]
244pub struct CapFilter {
245 #[serde(default, skip_serializing_if = "Names::is_empty")]
247 pub card: Names,
248 #[serde(default, skip_serializing_if = "Names::is_empty")]
250 pub model: Names,
251 #[serde(default, skip_serializing_if = "Names::is_empty")]
253 pub harness: Names,
254 #[serde(default, skip_serializing_if = "Names::is_empty")]
256 pub assignee: Names,
257 #[serde(default, skip_serializing_if = "Names::is_empty")]
259 pub status: Names,
260 #[serde(default, skip_serializing_if = "Names::is_empty")]
262 pub stored: Names,
263 #[serde(default, skip_serializing_if = "Names::is_empty")]
265 pub machine: Names,
266 #[serde(default, skip_serializing_if = "Names::is_empty")]
268 pub board: Names,
269}
270
271impl CapFilter {
272 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#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
291#[schemars(transparent)]
292pub struct Names(pub Vec<String>);
293
294impl Names {
295 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331#[serde(deny_unknown_fields)]
332pub struct Reserve {
333 #[serde(rename = "for")]
335 pub for_cards: CapFilter,
336 pub slots: Expr,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342#[serde(deny_unknown_fields)]
343pub struct Dispatch {
344 pub tick: Expr,
346 pub start: String,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub eligible: Option<Expr>,
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub order: Vec<Expr>,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub capacity: Vec<Cap>,
357}
358
359#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
361pub struct RunMachine {
362 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub recurring_role: Option<String>,
365 #[serde(default)]
367 pub limits: BTreeMap<String, Expr>,
368 #[serde(default)]
370 pub roles: BTreeMap<String, Run>,
371 #[serde(default)]
373 pub states: BTreeMap<String, Status>,
374 #[serde(default)]
376 pub initial: String,
377 #[serde(default)]
379 pub on: BTreeMap<String, Vec<Transition>>,
380 #[serde(default)]
382 pub outcomes: BTreeMap<String, Vec<Transition>>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
387pub struct StorageRule {
388 pub guard: Expr,
389 pub status: String,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
394pub struct Workflow {
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub run: Option<RunMachine>,
398 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
400 pub verbs: BTreeMap<String, Vec<Transition>>,
401 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
403 pub derived: BTreeMap<String, Expr>,
404 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
406 pub relations: BTreeMap<String, Value>,
407 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub flags: BTreeMap<String, Value>,
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
412 pub stored_as: Vec<StorageRule>,
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
414 pub losses: Vec<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub description: Option<String>,
418 #[serde(default, skip_serializing_if = "Map::is_empty")]
421 pub params: Map<String, Value>,
422 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
424 pub roles: BTreeMap<String, Expr>,
425 pub initial: Vec<Transition>,
427 pub statuses: BTreeMap<String, Status>,
429 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
431 pub on: BTreeMap<String, Vec<Transition>>,
432 pub dispatch: Dispatch,
434 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
437 pub notify: BTreeMap<String, BTreeMap<String, String>>,
438}
439
440pub fn hermes_instance() -> Workflow {
443 let mut workflow: Workflow =
444 serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses");
445 if let Some(run) = &mut workflow.run {
446 for (role, prompt) in [
447 ("triager", include_str!("prompts/triager.md")),
448 ("implementer", include_str!("prompts/implementer.md")),
449 ("reviewer", include_str!("prompts/reviewer.md")),
450 ] {
451 if let Some(spec) = run.roles.get_mut(role) {
452 spec.prompt = prompt.to_owned();
453 }
454 }
455 }
456 workflow
457}
458
459pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
461
462impl Workflow {
463 pub fn check(&self) -> Result<(), String> {
465 let known = |t: &Option<String>| match t {
466 Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
467 Err(format!("no status {name}"))
468 }
469 _ => Ok(()),
470 };
471 let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
472 all(&self.initial)?;
473 for ts in self.verbs.values() {
474 all(ts)?;
475 for t in ts {
476 for source in &t.from {
477 if !self.statuses.contains_key(source) {
478 return Err(format!("no source status {source}"));
479 }
480 }
481 }
482 }
483 if let Some(run) = &self.run {
484 if !run.states.contains_key(&run.initial) {
485 return Err(format!("no initial run state {}", run.initial));
486 }
487 for ts in run.outcomes.values() {
488 all(ts)?;
489 }
490 for ts in run
491 .on
492 .values()
493 .chain(run.states.values().flat_map(|s| s.on.values()))
494 {
495 for t in ts {
496 if let Some(target) = &t.target {
497 if !target.starts_with("{{") && !run.states.contains_key(target) {
498 return Err(format!("no run state {target}"));
499 }
500 }
501 }
502 }
503 for (name, spec) in &run.roles {
504 if !self.roles.contains_key(&spec.role) {
505 return Err(format!("run {name}: no role {}", spec.role));
506 }
507 }
508 }
509 for ts in self.on.values() {
510 all(ts)?;
511 }
512 for (name, s) in &self.statuses {
513 if let Some(role) = &s.run_role {
514 if !self
515 .run
516 .as_ref()
517 .is_some_and(|r| r.roles.contains_key(role))
518 {
519 return Err(format!("{name}: no run specification {role}"));
520 }
521 }
522 for ts in s.on.values() {
523 all(ts).map_err(|e| format!("{name}: {e}"))?;
524 }
525 all(&s.always).map_err(|e| format!("{name}: {e}"))?;
526 s.after
527 .iter()
528 .try_for_each(|d| known(&d.transition.target))
529 .map_err(|e| format!("{name}: {e}"))?;
530 if let Some(run) = &s.run {
531 if !self.roles.contains_key(&run.role) {
532 return Err(format!("{name}: its run names no role {}", run.role));
533 }
534 }
535 }
536 Ok(())
537 }
538}