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 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub serve: Option<Expr>,
347 pub tick: Expr,
349 pub start: String,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub eligible: Option<Expr>,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub order: Vec<Expr>,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub capacity: Vec<Cap>,
360}
361
362#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364pub struct RunMachine {
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub recurring_role: Option<String>,
368 #[serde(default)]
370 pub limits: BTreeMap<String, Expr>,
371 #[serde(default)]
373 pub roles: BTreeMap<String, Run>,
374 #[serde(default)]
376 pub states: BTreeMap<String, Status>,
377 #[serde(default)]
379 pub initial: String,
380 #[serde(default)]
382 pub on: BTreeMap<String, Vec<Transition>>,
383 #[serde(default)]
385 pub outcomes: BTreeMap<String, Vec<Transition>>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
390pub struct StorageRule {
391 pub guard: Expr,
392 pub status: String,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
397pub struct Workflow {
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub run: Option<RunMachine>,
401 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
403 pub verbs: BTreeMap<String, Vec<Transition>>,
404 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
406 pub derived: BTreeMap<String, Expr>,
407 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub relations: BTreeMap<String, Value>,
410 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
412 pub flags: BTreeMap<String, Value>,
413 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub description: Option<String>,
421 #[serde(default, skip_serializing_if = "Map::is_empty")]
424 pub params: Map<String, Value>,
425 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
427 pub roles: BTreeMap<String, Expr>,
428 pub initial: Vec<Transition>,
430 pub statuses: BTreeMap<String, Status>,
432 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
434 pub on: BTreeMap<String, Vec<Transition>>,
435 pub dispatch: Dispatch,
437 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
440 pub notify: BTreeMap<String, BTreeMap<String, String>>,
441}
442
443pub 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
462pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
464
465impl Workflow {
466 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}