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 machine: Names,
263 #[serde(default, skip_serializing_if = "Names::is_empty")]
265 pub board: Names,
266}
267
268impl CapFilter {
269 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#[derive(Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
287#[schemars(transparent)]
288pub struct Names(pub Vec<String>);
289
290impl Names {
291 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
327#[serde(deny_unknown_fields)]
328pub struct Reserve {
329 #[serde(rename = "for")]
331 pub for_cards: CapFilter,
332 pub slots: Expr,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338#[serde(deny_unknown_fields)]
339pub struct Dispatch {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub serve: Option<Expr>,
343 pub tick: Expr,
345 pub start: String,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub eligible: Option<Expr>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub order: Vec<Expr>,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub capacity: Vec<Cap>,
356}
357
358#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
360pub struct RunMachine {
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub recurring_role: Option<String>,
364 #[serde(default)]
366 pub limits: BTreeMap<String, Expr>,
367 #[serde(default)]
369 pub roles: BTreeMap<String, Run>,
370 #[serde(default)]
372 pub states: BTreeMap<String, Status>,
373 #[serde(default)]
375 pub initial: String,
376 #[serde(default)]
378 pub on: BTreeMap<String, Vec<Transition>>,
379 #[serde(default)]
381 pub outcomes: BTreeMap<String, Vec<Transition>>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
386pub struct StorageRule {
387 pub guard: Expr,
388 pub status: String,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
393pub struct Workflow {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub run: Option<RunMachine>,
397 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
399 pub verbs: BTreeMap<String, Vec<Transition>>,
400 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
402 pub derived: BTreeMap<String, Expr>,
403 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
405 pub relations: BTreeMap<String, Value>,
406 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
408 pub flags: BTreeMap<String, Value>,
409 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub description: Option<String>,
417 #[serde(default, skip_serializing_if = "Map::is_empty")]
420 pub params: Map<String, Value>,
421 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
423 pub roles: BTreeMap<String, Expr>,
424 pub initial: Vec<Transition>,
426 pub statuses: BTreeMap<String, Status>,
428 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
430 pub on: BTreeMap<String, Vec<Transition>>,
431 pub dispatch: Dispatch,
433 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
436 pub notify: BTreeMap<String, BTreeMap<String, String>>,
437}
438
439pub 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
458pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
460
461impl Workflow {
462 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}