Files
meguri/src/generation.rs
T
Aleksandr BogomiakovandClaude 4764f3545f Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration
file synchronized at startup; workspaces dropped from it are disabled, never
deleted. Instance administrators (a new role) see and help every workspace and
may delete disabled ones; workspace administrators and employees are derived
from the user's groups at every SSO sign-in or administrator edit, and the
employee permission set belongs to the workspace. Self-registration is gone:
users come from SSO (only when their groups map) or from the administrator.
Break-glass bootstrap accounts get an HMAC-derived hourly password that all
replicas agree on. First SSO sign-in links a matching email contact to the
user. The demo runs on the same model with a generated per-start password.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 13:28:43 +01:00

1301 lines
44 KiB
Rust

//! Bounded, deterministic expansion of typed workspace data and visual rule blocks.
use crate::{
domain::*,
engine,
error::{Error, Result, ensure},
};
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: Uuid,
#[serde(deserialize_with = "deserialize_node_kind")]
pub kind: String,
#[serde(default)]
pub key: String,
#[serde(default)]
pub value: Value,
#[serde(default)]
pub entries: Vec<Node>,
}
// Normalize legacy stored variables and history recursively without changing node IDs.
fn deserialize_node_kind<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<String, D::Error> {
let kind = String::deserialize(deserializer)?;
Ok(if kind == "record" {
"dictionary".into()
} else {
kind
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Variable {
pub id: Uuid,
pub name: String,
pub node: Node,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Binding {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<Binding>,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub value: Value,
#[serde(default)]
pub variable: Option<Uuid>,
#[serde(default)]
pub iteration: Option<Uuid>,
#[serde(default)]
pub key: Option<Box<Binding>>,
#[serde(default)]
pub output: Option<Uuid>,
#[serde(default)]
pub path: Vec<String>,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub suffix: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Iteration {
pub id: Uuid,
pub name: String,
pub source: Binding,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Predicate {
pub left: Binding,
pub operation: String,
pub right: Binding,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub delay: Binding,
pub recipient: Binding,
#[serde(default)]
pub user: bool,
#[serde(default)]
pub condition: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Condition {
pub field: String,
pub operation: String,
pub value: Binding,
#[serde(default)]
pub negate: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(from = "BlockWire")]
pub struct Block {
pub id: Uuid,
pub kind: String,
pub fields: BTreeMap<String, Binding>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub conditions: Vec<Condition>,
}
#[derive(Deserialize)]
struct BlockWire {
pub id: Uuid,
pub kind: String,
pub fields: BTreeMap<String, Binding>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub conditions: Vec<Condition>,
}
impl From<BlockWire> for Block {
fn from(mut value: BlockWire) -> Self {
if value.kind == "coverage" {
value.kind = "priority".into();
let items = ["primary", "secondary"]
.into_iter()
.map(|key| value.fields.remove(key).unwrap_or_default())
.collect();
value.fields.insert(
"participants".into(),
Binding {
kind: "list".into(),
items,
..Default::default()
},
);
}
Self {
id: value.id,
kind: value.kind,
fields: value.fields,
steps: value.steps,
conditions: value.conditions,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: Uuid,
pub name: String,
pub enabled: bool,
#[serde(default)]
pub iterations: Vec<Iteration>,
#[serde(default)]
pub when: Option<Predicate>,
#[serde(default)]
pub blocks: Vec<Block>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Managed {
pub id: Uuid,
pub kind: String,
pub rule: Uuid,
pub block: Uuid,
pub item: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Generation {
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(default)]
pub variables: Vec<Variable>,
#[serde(default)]
pub route_position: usize,
#[serde(default)]
pub managed: Vec<Managed>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Issue {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rule_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variable_id: Option<Uuid>,
#[serde(default)]
pub secondary: bool,
pub path: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct Change {
pub kind: String,
pub id: Uuid,
pub name: String,
pub action: String,
pub source: String,
}
pub struct Expansion {
pub trace: Vec<Value>,
pub document: WorkspaceDocument,
pub changes: Vec<Change>,
pub issues: Vec<Issue>,
}
pub fn stable_id(parts: &Value) -> Uuid {
let digest = Sha256::digest(parts.to_string().as_bytes());
let mut bytes = [0; 16];
bytes.copy_from_slice(&digest[..16]);
bytes[6] = (bytes[6] & 15) | 128;
bytes[8] = (bytes[8] & 63) | 128;
Uuid::from_bytes(bytes)
}
fn problem(path: impl Into<String>, message: impl Into<String>) -> Issue {
Issue {
path: path.into(),
message: message.into(),
..Issue::default()
}
}
fn validate_node(
node: &Node,
path: &str,
depth: usize,
seen: &mut BTreeSet<Uuid>,
issues: &mut Vec<Issue>,
) {
if depth > 8 || seen.len() >= 10000 {
issues.push(problem(
path,
crate::i18n::text("err_data_is_limited_to_8_nested_levels_and_10_000_va_fa5d71"),
));
return;
}
if !seen.insert(node.id) {
issues.push(problem(
path,
crate::i18n::text("err_data_entry_ids_must_be_unique_4725be"),
));
return;
}
if matches!(node.kind.as_str(), "list" | "dictionary") {
if node.entries.len() > 1000 {
issues.push(problem(
path,
crate::i18n::text("err_a_collection_supports_at_most_1_000_entries"),
));
return;
}
let mut keys = BTreeSet::new();
for (index, entry) in node.entries.iter().enumerate() {
let key = if node.kind == "list" {
(index + 1).to_string()
} else {
entry.key.clone()
};
if node.kind != "list"
&& (key.trim().is_empty() || key.len() > 120 || !keys.insert(key.clone()))
{
issues.push(problem(
format!("{path} / {key}"),
crate::i18n::text("err_dictionary_keys_must_be_nonempty_and_unique_4e8ce4"),
));
}
validate_node(entry, &format!("{path} / {key}"), depth + 1, seen, issues);
}
return;
}
let valid = match node.kind.as_str() {
"text" => node.value.as_str().is_some_and(|s| s.len() <= 2000),
"person" => node.value.as_str().is_some_and(|s| {
Uuid::parse_str(s).is_ok() || email_address::EmailAddress::is_valid(s)
}),
"number" | "duration" => node
.value
.as_f64()
.is_some_and(|n| n.is_finite() && (node.kind != "duration" || n >= 0.0)),
"boolean" => node.value.is_boolean(),
"date" => node
.value
.as_str()
.is_some_and(|s| s.parse::<NaiveDate>().is_ok()),
"time" => node.value.as_str().is_some_and(|s| {
NaiveTime::parse_from_str(s, "%H:%M")
.or_else(|_| s.parse())
.is_ok()
}),
_ => false,
};
if !valid || !node.entries.is_empty() {
issues.push(problem(
path,
crate::i18n::message(
"err_enter_a_valid_p0_value_3b830b",
&[("p0", (node.kind).to_string())],
),
));
}
}
pub fn validate_variables(variables: &[Variable]) -> Vec<Issue> {
let mut issues = vec![];
let mut names = BTreeSet::new();
let mut ids = BTreeSet::new();
let mut nodes = BTreeSet::new();
if variables.len() > 100 {
return vec![problem(
"Variables",
crate::i18n::text("err_use_at_most_100_workspace_variables_c7d391"),
)];
}
for v in variables {
let issue_start = issues.len();
let path = crate::i18n::message("err_variable_p0_973058", &[("p0", (v.name).to_string())]);
if v.name.trim().is_empty()
|| v.name.len() > 80
|| !names.insert(v.name.trim().to_lowercase())
|| !ids.insert(v.id)
{
issues.push(problem(
&path,
crate::i18n::text("err_choose_a_unique_variable_name_and_id_ee4fc3"),
));
}
validate_node(&v.node, &path, 0, &mut nodes, &mut issues);
for issue in &mut issues[issue_start..] {
issue.variable_id = Some(v.id);
}
}
issues
}
impl Node {
fn json(&self) -> Value {
match self.kind.as_str() {
"list" => Value::Array(self.entries.iter().map(Node::json).collect()),
"dictionary" => Value::Object(
self.entries
.iter()
.map(|e| (e.key.clone(), e.json()))
.collect(),
),
_ => self.value.clone(),
}
}
fn literal(value: Value) -> Self {
let id = stable_id(&json!(["literal", value]));
let (kind, entries) = match &value {
Value::Array(values) => (
"list",
values.iter().map(|v| Self::literal(v.clone())).collect(),
),
Value::Object(values) => (
"dictionary",
values
.iter()
.map(|(key, v)| {
let mut n = Self::literal(v.clone());
n.key = key.clone();
n
})
.collect(),
),
_ => ("literal", vec![]),
};
Self {
id,
kind: kind.into(),
key: String::new(),
value,
entries,
}
}
}
#[derive(Clone, Default)]
struct Context {
items: BTreeMap<Uuid, Node>,
keys: Vec<Uuid>,
labels: Vec<String>,
}
struct Resolver<'a> {
variables: &'a [Variable],
context: &'a Context,
outputs: &'a BTreeMap<Uuid, Uuid>,
}
impl Resolver<'_> {
fn eval(&self, b: &Binding, depth: usize) -> Result<Node> {
ensure(
depth <= 16,
crate::i18n::text("err_bindings_can_be_nested_at_most_16_levels_b78618"),
)?;
let var = || {
self.variables
.iter()
.find(|v| Some(v.id) == b.variable)
.map(|v| v.node.clone())
.ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_choose_an_existing_workspace_variable_db6738")
.into(),
)
})
};
let mut node = match b.kind.as_str() {
"literal" => Node::literal(b.value.clone()),
"list" => Node::literal(Value::Array(
b.items
.iter()
.map(|item| self.value(item))
.collect::<Result<Vec<_>>>()?,
)),
"variable" => var()?,
"item" => self
.context
.items
.get(&b.iteration.unwrap_or_default())
.cloned()
.ok_or_else(|| {
Error::Validation(
crate::i18n::text(
"err_choose_an_earlier_iteration_an_item_is_available_4aef3d",
)
.into(),
)
})?,
"key" => Node::literal(json!(
self.context
.items
.get(&b.iteration.unwrap_or_default())
.ok_or_else(|| Error::Validation(
crate::i18n::text("err_choose_an_earlier_iteration_3562e3").into()
))?
.key
)),
"lookup" => {
let dictionary = var()?;
ensure(
matches!(dictionary.kind.as_str(), "dictionary"),
crate::i18n::text("err_dictionary_lookup_needs_a_dictionary_variable_867cbc"),
)?;
let key = self
.eval(
b.key.as_ref().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_choose_the_dictionary_lookup_key_66d49d")
.into(),
)
})?,
depth + 1,
)?
.json();
let key = key.as_str().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_dictionary_keys_must_be_text_8bcea8").into(),
)
})?;
dictionary
.entries
.iter()
.find(|n| n.key == key)
.cloned()
.ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_no_responsible_value_entry_for_key_key_06146d",
&[("key", (key).to_string())],
))
})?
}
"output" => Node::literal(json!(
self.outputs.get(&b.output.unwrap_or_default()).ok_or_else(
|| Error::Validation(
crate::i18n::text(
"err_choose_an_output_block_in_this_rule_or_a_shared_0758e5"
)
.into()
)
)?
)),
_ => {
return Err(Error::Validation(
crate::i18n::text("err_choose_a_value_source_00b9ad").into(),
));
}
};
for key in &b.path {
node = if node.kind == "list" {
key.parse::<usize>()
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| node.entries.get(i))
.cloned()
} else {
node.entries.iter().find(|e| e.key == *key).cloned()
}
.ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_field_or_list_position_key_does_not_exist_4a5e94",
&[("key", (key).to_string())],
))
})?;
}
if !b.prefix.is_empty() || !b.suffix.is_empty() {
let value = node.json();
let text = value.as_str().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_name_prefixes_suffixes_need_a_text_value_96730e").into(),
)
})?;
node = Node::literal(json!(format!("{}{}{}", b.prefix, text, b.suffix)));
}
Ok(node)
}
fn value(&self, b: &Binding) -> Result<Value> {
Ok(self.eval(b, 0)?.json())
}
fn matches(&self, p: &Predicate) -> Result<bool> {
let left = self.value(&p.left)?;
if p.operation == "not_empty" {
return Ok(!left.is_null()
&& left.as_str().is_none_or(|s| !s.is_empty())
&& left.as_array().is_none_or(|a| !a.is_empty())
&& left.as_object().is_none_or(|o| !o.is_empty()));
}
let right = self.value(&p.right)?;
match p.operation.as_str() {
"equals" | "not_equals" => {
ensure(
std::mem::discriminant(&left) == std::mem::discriminant(&right),
crate::i18n::text("err_condition_values_must_have_the_same_type_e949f9"),
)?;
Ok((left == right) == (p.operation == "equals"))
}
"contains" => {
if let Some(items) = left.as_array() {
Ok(items.contains(&right))
} else if let Some((a, b)) = left.as_str().zip(right.as_str()) {
Ok(a.contains(b))
} else {
Err(Error::Validation(
crate::i18n::text(
"err_contains_needs_a_list_and_a_value_or_two_text_va_b878ec",
)
.into(),
))
}
}
_ => Err(Error::Validation(
crate::i18n::text("err_choose_a_supported_condition_f7daba").into(),
)),
}
}
}
fn value(fields: &BTreeMap<String, Binding>, key: &str, resolver: &Resolver<'_>) -> Result<Value> {
resolver
.value(fields.get(key).ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_set_key_a7389f",
&[("key", (key).to_string())],
))
})?)
.map_err(|e| Error::Validation(format!("{key}: {e}")))
}
fn text(fields: &BTreeMap<String, Binding>, key: &str, resolver: &Resolver<'_>) -> Result<String> {
value(fields, key, resolver)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err__key_must_be_text_4fd030",
&[("key", (key).to_string())],
))
})
}
fn number(
fields: &BTreeMap<String, Binding>,
key: &str,
resolver: &Resolver<'_>,
default: u64,
) -> Result<u64> {
if !fields.contains_key(key) {
return Ok(default);
}
value(fields, key, resolver)?.as_u64().ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err__key_must_be_a_nonnegative_whole_number_2fb848",
&[("key", (key).to_string())],
))
})
}
fn recipient(
value: &Value,
workspace: Uuid,
doc: &mut WorkspaceDocument,
members: &BTreeSet<Uuid>,
) -> Result<Uuid> {
let value = value.as_str().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_choose_a_person_or_enter_an_email_address_a24e0a").into(),
)
})?;
if let Ok(id) = Uuid::parse_str(value) {
ensure(
members.contains(&id) || doc.person(id).is_some(),
crate::i18n::text("err_person_is_not_in_this_workspace_or_team_director_c6d509"),
)?;
return Ok(id);
}
let email = value.trim().to_lowercase();
ensure(
email_address::EmailAddress::is_valid(&email),
crate::i18n::message(
"err__value_is_not_an_email_address_a7e20a",
&[("value", (value).to_string())],
),
)?;
if let Some(person) = doc
.people
.iter()
.find(|p| p.email.as_deref() == Some(email.as_str()))
{
return Ok(person.id);
}
let id = stable_id(&json!(["generation-contact", workspace, email]));
doc.people.push(Person {
id,
user_id: None,
display_name: email.clone(),
email: Some(email),
country: Some("CY".into()),
aliases: vec![],
});
Ok(id)
}
fn id_value(v: Value) -> Result<Uuid> {
v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_choose_a_schedule_or_policy_reference_f55953").into(),
)
})
}
fn render(
block: &Block,
id: Uuid,
workspace: Uuid,
resolver: &Resolver<'_>,
doc: &mut WorkspaceDocument,
members: &BTreeSet<Uuid>,
) -> Result<()> {
let f = &block.fields;
let name = text(f, "name", resolver)?;
ensure(
!name.trim().is_empty() && name.len() <= 120,
crate::i18n::text("err_name_needs_1_120_characters_408c00"),
)?;
if ["rotation", "priority"].contains(&block.kind.as_str()) {
let handover = if f.contains_key("handover") {
let time = text(f, "handover", resolver)?;
NaiveTime::parse_from_str(&time, "%H:%M")
.or_else(|_| time.parse())
.map_err(|_| {
Error::Validation(crate::i18n::text("err_handover_use_hh_mm_25d33e").into())
})?
} else {
default_handover()
};
let kind = match block.kind.as_str() {
"rotation" | "priority" => {
let list = value(f, "participants", resolver)?;
let list = list.as_array().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_participants_choose_a_list_of_people_emails_d13655")
.into(),
)
})?;
ensure(
!list.is_empty() && list.len() <= 100,
crate::i18n::text("err_participants_choose_1_100_people_45308a"),
)?;
let participants = list
.iter()
.map(|v| recipient(v, workspace, doc, members))
.collect::<Result<Vec<_>>>()?;
ensure(
participants.iter().collect::<BTreeSet<_>>().len() == participants.len(),
"participants: a person occurs more than once",
)?;
if block.kind == "priority" {
ScheduleKind::Priority {
participants,
fallback: id_value(value(f, "fallback", resolver)?)?,
}
} else {
let anchor = text(f, "anchor", resolver)?.parse().map_err(|_| {
Error::Validation(
crate::i18n::text("err_anchor_choose_a_valid_date_497062").into(),
)
})?;
let days = number(f, "shift_days", resolver, 7)?;
ensure(
(1..=366).contains(&days),
crate::i18n::text("err_shift_days_use_1_366_days_801e00"),
)?;
ScheduleKind::Rotation {
participants,
anchor,
shift_days: days as u16,
}
}
}
_ => unreachable!(),
};
doc.schedules.push(Schedule {
id,
name,
handover,
kind,
});
} else if block.kind == "escalation" {
ensure(
!block.steps.is_empty() && block.steps.len() <= 20,
crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"),
)?;
let mut steps = vec![];
for (i, step) in block.steps.iter().enumerate() {
let delay = resolver
.value(&step.delay)?
.as_u64()
.filter(|n| *n <= 10080)
.ok_or_else(|| {
Error::Validation(crate::i18n::message(
"err_step_p0_delay_must_be_0_10080_minutes_b384d0",
&[("p0", (i + 1).to_string())],
))
})?;
let target = resolver.value(&step.recipient)?;
steps.push(EscalationStep {
delay_minutes: delay as u16,
condition: if step.condition == "if-not-closed" {
EscalationCondition::IfNotClosed
} else {
EscalationCondition::IfNotAcked
},
recipient: if step.user {
Recipient::User {
id: recipient(&target, workspace, doc, members)?,
}
} else {
Recipient::Schedule {
id: id_value(target)?,
}
},
});
}
let count = number(f, "repeat_count", resolver, 0)?;
let wait = number(f, "repeat_wait", resolver, 0)?;
ensure(
count <= 20 && wait <= 10080,
crate::i18n::text("err_escalation_repeat_is_limited_to_20_repeats_10080_9b06f3"),
)?;
doc.escalations.push(Escalation {
id,
name,
steps,
repeat: EscalationRepeat {
count: count as u8,
wait_minutes: wait as u16,
..Default::default()
},
});
} else if block.kind == "route" {
let mut conditions = vec![];
for c in &block.conditions {
let raw = resolver.value(&c.value)?;
conditions.push(RouteCondition {
field: c.field.clone(),
operation: c.operation.clone(),
negate: c.negate,
value: raw
.as_str()
.ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_routing_condition_value_must_be_text_233f0e")
.into(),
)
})?
.into(),
});
}
let policy = if f.contains_key("escalation") {
Some(id_value(value(f, "escalation", resolver)?)?)
} else {
None
};
let working = if f.contains_key("working") {
value(f, "working", resolver)?.as_bool().ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_working_must_be_true_or_false_10625c").into(),
)
})?
} else {
false
};
doc.routes.push(RoutingRule {
id,
name,
order: 0,
conditions,
escalation_id: policy,
window: working.then(WorkingWindow::default),
});
} else {
return Err(Error::Validation(
crate::i18n::text("err_choose_an_output_type_4ab1b5").into(),
));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn expand(
doc: &WorkspaceDocument,
workspace: Uuid,
variables: &[Variable],
rules: &[Rule],
route_position: usize,
interval: &WorkingWindow,
members: &BTreeSet<Uuid>,
now: DateTime<Utc>,
) -> Expansion {
let mut trace = vec![];
let mut issues = validate_variables(variables);
if rules.len() > 50 {
issues.push(problem(
"Rules",
crate::i18n::text("err_use_at_most_50_generation_rules_abf459"),
));
}
let mut output = doc.clone();
let old_ids: BTreeSet<_> = doc.generation.managed.iter().map(|m| m.id).collect();
output.schedules.retain(|s| !old_ids.contains(&s.id));
output.escalations.retain(|s| !old_ids.contains(&s.id));
output.routes.retain(|s| !old_ids.contains(&s.id));
let manual_routes = output.routes.clone();
output.routes.clear();
let manual_ids: BTreeSet<_> = output
.schedules
.iter()
.map(|s| s.id)
.chain(output.escalations.iter().map(|s| s.id))
.chain(manual_routes.iter().map(|s| s.id))
.collect();
let mut managed = vec![];
let mut block_ids = BTreeSet::new();
let mut rule_ids = BTreeSet::new();
if !issues.is_empty() {
return Expansion {
trace,
document: doc.clone(),
changes: vec![],
issues,
};
}
// Singleton outputs are shared across rules; iterated outputs stay local to their row.
let shared: BTreeMap<_, _> = rules
.iter()
.filter(|r| r.enabled && r.iterations.is_empty())
.flat_map(|r| {
r.blocks.iter().map(move |b| {
(
b.id,
stable_id(&json!(["generated", workspace, r.id, [], b.id])),
)
})
})
.collect();
for rule in rules {
trace.push(json!({"rule":rule.id,"enabled":rule.enabled,"stages":[],"rows":[]}));
let issue_start = issues.len();
let path = crate::i18n::message("err_rule_p0_eb6431", &[("p0", (rule.name).to_string())]);
if !rule_ids.insert(rule.id)
|| rule.name.trim().is_empty()
|| rule.name.len() > 120
|| rule.iterations.len() > 4
|| rule.blocks.len() > 20
{
issues.push(problem(
&path,
crate::i18n::text("err_use_a_unique_rule_id_name_at_most_4_iterations_a_c413b3"),
));
continue;
}
if !rule.enabled {
continue;
}
let mut contexts = vec![Context::default()];
let mut loop_ids = BTreeSet::new();
for iteration in &rule.iterations {
if !loop_ids.insert(iteration.id) {
issues.push(problem(
&path,
crate::i18n::text("err_iteration_ids_must_be_unique_bfb5cc"),
));
contexts.clear();
break;
}
let mut next = vec![];
for context in contexts {
let empty = BTreeMap::new();
let resolver = Resolver {
variables,
context: &context,
outputs: &empty,
};
match resolver.eval(&iteration.source, 0) {
Ok(node) if matches!(node.kind.as_str(), "list" | "dictionary") => {
let mut values = BTreeSet::new();
for entry in node.entries {
let label = if node.kind == "list" {
entry
.json()
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| entry.id.to_string())
} else {
entry.key.clone()
};
if !values.insert(label.clone()) {
issues.push(problem(
&path,
crate::i18n::message(
"err_iteration_p0_contains_duplicate_value_label_1743fe",
&[
("p0", (iteration.name).to_string()),
("label", (label).to_string()),
],
),
));
continue;
}
let mut child = context.clone();
child.keys.push(entry.id);
child.labels.push(label.clone());
let mut entry = entry;
if node.kind == "list" {
entry.key = label;
}
child.items.insert(iteration.id, entry);
next.push(child);
if next.len() > 500 {
break;
}
}
}
Ok(_) => issues.push(problem(
format!("{path} / {}", iteration.name),
crate::i18n::text("err_for_each_needs_a_list_or_dictionary_c87f69"),
)),
Err(e) => issues.push(problem(
format!(
"{path} / {} / {}",
iteration.name,
context.labels.join(" / ")
),
e.to_string(),
)),
}
if next.len() > 500 {
break;
}
}
if next.len() > 500 {
issues.push(problem(
&path,
crate::i18n::text(
"err_expansion_is_limited_to_500_iteration_combinatio_81a6ef",
),
));
contexts = vec![];
break;
}
trace.last_mut().unwrap()["stages"]
.as_array_mut()
.unwrap()
.push(json!({"iteration":iteration.id,"count":next.len()}));
contexts = next;
}
for block in &rule.blocks {
if !block_ids.insert(block.id) {
issues.push(problem(
&path,
crate::i18n::text("err_output_block_ids_must_be_unique_across_rules_1efed6"),
));
}
}
for context in contexts {
let mut ids = shared.clone();
ids.extend(rule.blocks.iter().map(|b| {
(
b.id,
stable_id(&json!([
"generated",
workspace,
rule.id,
context.keys,
b.id
])),
)
}));
let resolver = Resolver {
variables,
context: &context,
outputs: &ids,
};
let row_path = if context.labels.is_empty() {
path.clone()
} else {
format!("{path} / {}", context.labels.join(" / "))
};
let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::<BTreeMap<_,_>>(),"status":"included","resources":[],"blocks":[]});
let managed_start = managed.len();
let row_issue_start = issues.len();
if let Some(predicate) = &rule.when {
row_trace["condition"] = json!({"left":resolver.eval(&predicate.left,0).ok().map(|n|n.json()),"operation":predicate.operation,"right":resolver.eval(&predicate.right,0).ok().map(|n|n.json())});
match resolver.matches(predicate) {
Ok(true) => {}
Ok(false) => {
row_trace["status"] = json!("filtered");
trace.last_mut().unwrap()["rows"]
.as_array_mut()
.unwrap()
.push(row_trace);
continue;
}
Err(e) => {
issues.push(problem(&row_path, e.to_string()));
row_trace["status"] = json!("error");
trace.last_mut().unwrap()["rows"]
.as_array_mut()
.unwrap()
.push(row_trace);
continue;
}
}
}
for (index, block) in rule.blocks.iter().enumerate() {
let id = ids[&block.id];
let resolved = |binding: &Binding| {
resolver
.eval(binding, 0)
.map(|n| n.json())
.unwrap_or(Value::Null)
};
row_trace["blocks"].as_array_mut().unwrap().push(json!({
"block":block.id,
"fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::<BTreeMap<_,_>>(),
"steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::<Vec<_>>(),
"conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::<Vec<_>>()
}));
if manual_ids.contains(&id) {
issues.push(problem(
&row_path,
crate::i18n::text(
"err_generated_identity_collides_with_a_manual_resour_4403f1",
),
));
continue;
}
if managed.len() >= 900 {
issues.push(problem(
&path,
crate::i18n::text("err_expansion_exceeds_the_team_resource_budget_5669f8"),
));
break;
}
match render(block, id, workspace, &resolver, &mut output, members) {
Ok(()) => managed.push(Managed {
id,
kind: block.kind.clone(),
rule: rule.id,
block: block.id,
item: context.labels.join(" / "),
}),
Err(e) => {
let mut issue = problem(
format!("{row_path} / {} block {}", block.kind, index + 1),
e.to_string(),
);
issue.rule_id = Some(rule.id);
issue.block_id = Some(block.id);
issues.push(issue);
}
}
}
if issues.len() > row_issue_start {
row_trace["status"] = json!("error");
}
row_trace["resources"] = json!(
managed[managed_start..]
.iter()
.map(|m| m.id)
.collect::<Vec<_>>()
);
trace.last_mut().unwrap()["rows"]
.as_array_mut()
.unwrap()
.push(row_trace);
}
for issue in &mut issues[issue_start..] {
issue.rule_id = Some(rule.id);
}
}
let current_ids: BTreeSet<_> = managed.iter().map(|m| m.id).collect();
let removed: BTreeSet<_> = old_ids.difference(&current_ids).copied().collect();
for request in &mut output.requests {
if !request
.legs
.iter()
.any(|l| removed.contains(&l.schedule_id))
{
continue;
}
if request.status == RequestStatus::Applied
&& request
.effective_legs()
.iter()
.any(|l| removed.contains(&l.schedule_id) && l.end > now)
{
issues.push(problem(
"Removal",
crate::i18n::text("err_a_generated_schedule_has_current_future_approved_0abed7"),
));
}
if request.status == RequestStatus::Pending {
request.status = RequestStatus::Cancelled;
}
}
let mut generated_routes = std::mem::take(&mut output.routes);
for route in &mut generated_routes {
if route.window.is_some() {
route.window = Some(interval.clone());
}
}
let mut routes = manual_routes;
routes.sort_by_key(|r| r.order);
let position = route_position.min(routes.len());
routes.splice(position..position, generated_routes);
for (i, r) in routes.iter_mut().enumerate() {
r.order = i as u16;
}
output.routes = routes;
output.generation = Generation {
rules: rules.to_vec(),
variables: variables.to_vec(),
route_position: position,
managed,
};
if let Err(e) = output.validate(members) {
let mut issue = problem(
crate::i18n::text("err_generated_configuration_61fd64"),
e.to_string(),
);
issue.secondary = !issues.is_empty();
issues.push(issue);
}
if issues.is_empty()
&& let Err(e) = engine::timeline(
&output,
now,
now + chrono::Duration::days(i64::from(output.horizon_days)),
)
{
issues.push(problem(
crate::i18n::text("err_generated_calendar_5e4edc"),
e.to_string(),
));
}
let before = resources(doc);
let after = resources(&output);
let mut changes = vec![];
for m in &output.generation.managed {
let Some((name, value)) = after.get(&m.id) else {
continue;
};
let order_changed = m.kind == "route"
&& doc.routes.iter().find(|r| r.id == m.id).map(|r| r.order)
!= output.routes.iter().find(|r| r.id == m.id).map(|r| r.order);
let action = match before.get(&m.id) {
None => "create",
Some((_, old)) if old == value && !order_changed => "unchanged",
_ => "update",
};
changes.push(Change {
kind: m.kind.clone(),
id: m.id,
name: name.clone(),
action: action.into(),
source: m.item.clone(),
});
}
for m in &doc.generation.managed {
if removed.contains(&m.id) {
changes.push(Change {
kind: m.kind.clone(),
id: m.id,
name: before
.get(&m.id)
.map(|(n, _)| n.clone())
.unwrap_or_default(),
action: "remove".into(),
source: m.item.clone(),
});
}
}
Expansion {
trace,
document: output,
changes,
issues,
}
}
fn resources(doc: &WorkspaceDocument) -> BTreeMap<Uuid, (String, Value)> {
let mut map = BTreeMap::new();
for s in &doc.schedules {
map.insert(
s.id,
(
s.name.clone(),
serde_json::to_value(s).expect("serializable schedule"),
),
);
}
for s in &doc.escalations {
map.insert(
s.id,
(
s.name.clone(),
serde_json::to_value(s).expect("serializable policy"),
),
);
}
for s in &doc.routes {
let mut value = serde_json::to_value(s).expect("serializable route");
value.as_object_mut().unwrap().remove("order");
map.insert(s.id, (s.name.clone(), value));
}
map
}
pub fn validate_manual_edit(
before: &WorkspaceDocument,
after: &mut WorkspaceDocument,
) -> Result<()> {
ensure(
serde_json::to_value(&before.generation)? == serde_json::to_value(&after.generation)?,
crate::i18n::text("err_edit_generation_sources_in_variables_rules_8ec0b0"),
)?;
let old = resources(before);
let new = resources(after);
ensure(
before
.generation
.managed
.iter()
.all(|m| old.get(&m.id) == new.get(&m.id)),
crate::i18n::text("err_this_resource_is_generated_edit_its_source_in_va_b45498"),
)?;
validate_route_block(after)
}
pub fn validate_route_block(doc: &mut WorkspaceDocument) -> Result<()> {
let expected: Vec<_> = doc
.generation
.managed
.iter()
.filter(|m| m.kind == "route")
.map(|m| m.id)
.collect();
if expected.is_empty() {
return Ok(());
}
let mut sorted = doc.routes.clone();
sorted.sort_by_key(|r| r.order);
let start = sorted
.iter()
.position(|r| r.id == expected[0])
.ok_or_else(|| {
Error::Validation(
crate::i18n::text("err_a_generated_routing_rule_is_missing_df7dc4").into(),
)
})?;
ensure(
sorted
.iter()
.skip(start)
.take(expected.len())
.map(|r| r.id)
.collect::<Vec<_>>()
== expected,
crate::i18n::text("err_generated_routing_rules_move_as_one_block_set_th_d09425"),
)?;
doc.generation.route_position = start;
Ok(())
}