use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value};
#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(transparent)]
pub struct Expr(pub String);
impl Serialize for Expr {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de> Deserialize<'de> for Expr {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(Self(s)),
v @ (Value::Number(_) | Value::Bool(_) | Value::Null) => Ok(Self(v.to_string())),
other => Err(D::Error::custom(format!(
"an expression is a string or a literal, not {other}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
pub struct Action {
pub name: String,
pub args: Value,
}
impl Serialize for Action {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = Map::new();
m.insert(self.name.clone(), self.args.clone());
Value::Object(m).serialize(s)
}
}
impl<'de> Deserialize<'de> for Action {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(name) => Ok(Self {
name,
args: Value::Null,
}),
Value::Object(m) if m.len() == 1 => {
let (name, args) = m.into_iter().next().unwrap();
Ok(Self { name, args })
}
other => Err(D::Error::custom(format!(
"an action is a name or a one-key map, not {other}"
))),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Transition {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub by: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guard: Option<Expr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub validate: Vec<Validation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Validation {
pub expr: Expr,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Delayed {
pub after: Expr,
#[serde(flatten)]
pub transition: Transition,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Run {
pub role: String,
pub session: String,
pub prompt: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub limits: BTreeMap<String, Expr>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Status {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stored_as: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<Run>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entry: Vec<Action>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exit: Vec<Action>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub on: BTreeMap<String, Vec<Transition>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub always: Vec<Transition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub after: Vec<Delayed>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Limit {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub group: Expr,
pub counts: Expr,
pub max: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reserve: Option<Reserve>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Reserve {
#[serde(rename = "for")]
pub for_cards: Expr,
pub slots: Expr,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Dispatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serve: Option<Expr>,
pub tick: Expr,
pub start: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eligible: Option<Expr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub order: Vec<Expr>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub limits: Vec<Limit>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Workflow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub params: Map<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub roles: BTreeMap<String, Expr>,
pub initial: Vec<Transition>,
pub statuses: BTreeMap<String, Status>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub on: BTreeMap<String, Vec<Transition>>,
pub dispatch: Dispatch,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub notify: BTreeMap<String, BTreeMap<String, String>>,
}
pub fn hermes_instance() -> Workflow {
serde_yaml::from_str(HERMES_WORKFLOW).expect("the built-in Hermes workflow parses")
}
pub const HERMES_WORKFLOW: &str = include_str!("hermes.workflow.yaml");
impl Workflow {
pub fn check(&self) -> Result<(), String> {
let known = |t: &Option<String>| match t {
Some(name) if !name.starts_with("{{") && !self.statuses.contains_key(name) => {
Err(format!("no status {name}"))
}
_ => Ok(()),
};
let all = |ts: &[Transition]| ts.iter().try_for_each(|t| known(&t.target));
all(&self.initial)?;
for ts in self.on.values() {
all(ts)?;
}
for (name, s) in &self.statuses {
for ts in s.on.values() {
all(ts).map_err(|e| format!("{name}: {e}"))?;
}
all(&s.always).map_err(|e| format!("{name}: {e}"))?;
s.after
.iter()
.try_for_each(|d| known(&d.transition.target))
.map_err(|e| format!("{name}: {e}"))?;
if let Some(run) = &s.run {
if !self.roles.contains_key(&run.role) {
return Err(format!("{name}: its run names no role {}", run.role));
}
}
}
Ok(())
}
}