use std::collections::BTreeMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::WorkflowId;
pub const WORKFLOW_KIND_ATTRIBUTE: &str = "aion.kind";
pub const WORKLOOP_KIND: &str = "workloop";
#[must_use]
pub fn workflow_kind_from_attributes<S: std::hash::BuildHasher>(
attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
) -> Option<String> {
match attributes.get(WORKFLOW_KIND_ATTRIBUTE) {
Some(crate::SearchAttributeValue::String(kind)) => Some(kind.clone()),
_ => None,
}
}
#[must_use]
pub fn workflow_kind(events: &[crate::Event]) -> Option<String> {
workflow_kind_from_attributes(&crate::search_attributes_from_events(events))
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AlarmCause {
SampleRed,
WindowMissed,
LoopDead,
UnconfirmedUnknown,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
pub enum HealthStatus {
Confirmed,
Unconfirmed,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct HealthSample {
pub invariant: String,
pub status: HealthStatus,
pub window_seq: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct InvariantAlarm {
pub invariant: String,
pub cause: AlarmCause,
pub window_seq: Option<u64>,
pub last_confirmed_at: Option<DateTime<Utc>>,
pub consecutive_unconfirmed: u64,
}
#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
pub enum WorkloopSpecError {
#[error("tolerance must declare at least one form (N windows or unconfirmed-for duration)")]
ToleranceUndeclared,
#[error("tolerance duration must be greater than zero")]
ToleranceZeroDuration,
#[error("cadence period must be greater than zero")]
ZeroCadencePeriod,
#[error("signal arming must name at least one signal")]
NoSignals,
#[error("signal names must be non-empty")]
EmptySignalName,
#[error(
"invariant `{invariant}` on a signal-only workloop must declare the duration-form \
tolerance: with no windows to miss, a count of samples can never alarm on total silence"
)]
SignalOnlyNeedsDurationTolerance {
invariant: String,
},
#[error("invariant names must be non-empty")]
EmptyInvariantName,
#[error("invariant `{invariant}` is declared more than once")]
DuplicateInvariant {
invariant: String,
},
#[error("invariant `{invariant}` must declare the type of its current-state record")]
MissingRecordType {
invariant: String,
},
#[error(
"invariant `{invariant}` must declare at least one confirming route; an invariant \
nothing can confirm alarms unconditionally"
)]
NoConfirmingRoutes {
invariant: String,
},
#[error("confirming route names on invariant `{invariant}` must be non-empty")]
EmptyConfirmingRoute {
invariant: String,
},
#[error(
"a workloop must declare at least one invariant; a loop with no invariants has no \
health surface and rebuilds the silent-death family"
)]
NoInvariants,
#[error("retention window must be greater than zero")]
ZeroRetention,
#[error(
"retention window of {seconds}s cannot be expressed as a calendar duration, so no \
retention cutoff can be derived from it; declare a window the clock can subtract"
)]
UnrepresentableRetention {
seconds: u64,
},
#[error("carry field names must be non-empty")]
EmptyCarryField,
#[error("a workloop start payload carrying declared carry fields must be a JSON document")]
CarrySeedTargetNotJson,
#[error(
"a workloop start payload must be a JSON object so declared carry fields can be seeded \
into it; seeding a scalar or an array has nowhere to put a named field"
)]
CarrySeedTargetNotAnObject,
#[error("hatch identity parts (namespace, workflow type, key) must be non-empty")]
EmptyHatchIdentityPart,
#[error("hatch identity parts must not contain NUL bytes")]
HatchIdentityNulByte,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(try_from = "ToleranceSpecWire", into = "ToleranceSpecWire")]
pub struct ToleranceSpec {
consecutive_windows: Option<u64>,
unconfirmed_for: Option<Duration>,
}
impl ToleranceSpec {
#[must_use]
pub const fn count(windows: u64) -> Self {
Self {
consecutive_windows: Some(windows),
unconfirmed_for: None,
}
}
pub const fn duration(unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
if unconfirmed_for.is_zero() {
return Err(WorkloopSpecError::ToleranceZeroDuration);
}
Ok(Self {
consecutive_windows: None,
unconfirmed_for: Some(unconfirmed_for),
})
}
pub const fn both(windows: u64, unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
if unconfirmed_for.is_zero() {
return Err(WorkloopSpecError::ToleranceZeroDuration);
}
Ok(Self {
consecutive_windows: Some(windows),
unconfirmed_for: Some(unconfirmed_for),
})
}
#[must_use]
pub const fn consecutive_windows(&self) -> Option<u64> {
self.consecutive_windows
}
#[must_use]
pub const fn unconfirmed_for(&self) -> Option<Duration> {
self.unconfirmed_for
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct ToleranceSpecWire {
consecutive_windows: Option<u64>,
unconfirmed_for: Option<Duration>,
}
impl TryFrom<ToleranceSpecWire> for ToleranceSpec {
type Error = WorkloopSpecError;
fn try_from(wire: ToleranceSpecWire) -> Result<Self, Self::Error> {
match (wire.consecutive_windows, wire.unconfirmed_for) {
(None, None) => Err(WorkloopSpecError::ToleranceUndeclared),
(Some(windows), None) => Ok(Self::count(windows)),
(None, Some(duration)) => Self::duration(duration),
(Some(windows), Some(duration)) => Self::both(windows, duration),
}
}
}
impl From<ToleranceSpec> for ToleranceSpecWire {
fn from(spec: ToleranceSpec) -> Self {
Self {
consecutive_windows: spec.consecutive_windows,
unconfirmed_for: spec.unconfirmed_for,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(try_from = "WorkloopArmingWire", into = "WorkloopArmingWire")]
pub struct WorkloopArming {
every: Option<Duration>,
signals: Vec<String>,
}
impl WorkloopArming {
pub fn every(period: Duration) -> Result<Self, WorkloopSpecError> {
if period.is_zero() {
return Err(WorkloopSpecError::ZeroCadencePeriod);
}
Ok(Self {
every: Some(period),
signals: Vec::new(),
})
}
pub fn every_with_signals(
period: Duration,
signals: Vec<String>,
) -> Result<Self, WorkloopSpecError> {
if period.is_zero() {
return Err(WorkloopSpecError::ZeroCadencePeriod);
}
validate_signals(&signals, false)?;
Ok(Self {
every: Some(period),
signals,
})
}
pub fn signal_only(signals: Vec<String>) -> Result<Self, WorkloopSpecError> {
validate_signals(&signals, true)?;
Ok(Self {
every: None,
signals,
})
}
#[must_use]
pub const fn cadence_period(&self) -> Option<Duration> {
self.every
}
#[must_use]
pub fn signals(&self) -> &[String] {
&self.signals
}
#[must_use]
pub const fn is_signal_only(&self) -> bool {
self.every.is_none()
}
}
fn validate_signals(signals: &[String], require_nonempty: bool) -> Result<(), WorkloopSpecError> {
if require_nonempty && signals.is_empty() {
return Err(WorkloopSpecError::NoSignals);
}
if signals.iter().any(String::is_empty) {
return Err(WorkloopSpecError::EmptySignalName);
}
Ok(())
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct WorkloopArmingWire {
every: Option<Duration>,
signals: Vec<String>,
}
impl TryFrom<WorkloopArmingWire> for WorkloopArming {
type Error = WorkloopSpecError;
fn try_from(wire: WorkloopArmingWire) -> Result<Self, Self::Error> {
match wire.every {
Some(period) if wire.signals.is_empty() => Self::every(period),
Some(period) => Self::every_with_signals(period, wire.signals),
None => Self::signal_only(wire.signals),
}
}
}
impl From<WorkloopArming> for WorkloopArmingWire {
fn from(arming: WorkloopArming) -> Self {
Self {
every: arming.every,
signals: arming.signals,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct InvariantSpec {
pub name: String,
pub record_type: String,
pub tolerance: ToleranceSpec,
pub confirms: Vec<String>,
}
impl InvariantSpec {
fn validate(&self) -> Result<(), WorkloopSpecError> {
if self.name.is_empty() {
return Err(WorkloopSpecError::EmptyInvariantName);
}
if self.record_type.is_empty() {
return Err(WorkloopSpecError::MissingRecordType {
invariant: self.name.clone(),
});
}
if self.confirms.is_empty() {
return Err(WorkloopSpecError::NoConfirmingRoutes {
invariant: self.name.clone(),
});
}
if self.confirms.iter().any(String::is_empty) {
return Err(WorkloopSpecError::EmptyConfirmingRoute {
invariant: self.name.clone(),
});
}
Ok(())
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
#[serde(try_from = "CarryContractWire", into = "CarryContractWire")]
pub struct CarryContract {
defaults: BTreeMap<String, serde_json::Value>,
}
impl CarryContract {
#[must_use]
pub fn none() -> Self {
Self::default()
}
pub fn new(defaults: BTreeMap<String, serde_json::Value>) -> Result<Self, WorkloopSpecError> {
if defaults.keys().any(String::is_empty) {
return Err(WorkloopSpecError::EmptyCarryField);
}
Ok(Self { defaults })
}
#[must_use]
pub const fn defaults(&self) -> &BTreeMap<String, serde_json::Value> {
&self.defaults
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.defaults.is_empty()
}
pub fn seed(&self, input: &crate::Payload) -> Result<crate::Payload, WorkloopSpecError> {
if self.defaults.is_empty() {
return Ok(input.clone());
}
let mut document: serde_json::Value = serde_json::from_slice(input.bytes())
.map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
let object = document
.as_object_mut()
.ok_or(WorkloopSpecError::CarrySeedTargetNotAnObject)?;
for (field, default) in &self.defaults {
if !object.contains_key(field) {
object.insert(field.clone(), default.clone());
}
}
let bytes =
serde_json::to_vec(&document).map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
Ok(crate::Payload::new(crate::ContentType::Json, bytes))
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CarryContractWire {
defaults: BTreeMap<String, serde_json::Value>,
}
impl TryFrom<CarryContractWire> for CarryContract {
type Error = WorkloopSpecError;
fn try_from(wire: CarryContractWire) -> Result<Self, Self::Error> {
Self::new(wire.defaults)
}
}
impl From<CarryContract> for CarryContractWire {
fn from(contract: CarryContract) -> Self {
Self {
defaults: contract.defaults,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(try_from = "WorkloopSpecWire", into = "WorkloopSpecWire")]
pub struct WorkloopSpec {
arming: WorkloopArming,
invariants: Vec<InvariantSpec>,
retention: Duration,
carry: CarryContract,
}
impl WorkloopSpec {
pub fn new(
arming: WorkloopArming,
invariants: Vec<InvariantSpec>,
retention: Duration,
) -> Result<Self, WorkloopSpecError> {
Self::with_carry(arming, invariants, retention, CarryContract::none())
}
pub fn with_carry(
arming: WorkloopArming,
invariants: Vec<InvariantSpec>,
retention: Duration,
carry: CarryContract,
) -> Result<Self, WorkloopSpecError> {
if invariants.is_empty() {
return Err(WorkloopSpecError::NoInvariants);
}
if retention.is_zero() {
return Err(WorkloopSpecError::ZeroRetention);
}
if chrono::Duration::from_std(retention).is_err() {
return Err(WorkloopSpecError::UnrepresentableRetention {
seconds: retention.as_secs(),
});
}
let mut seen = std::collections::HashSet::new();
for invariant in &invariants {
invariant.validate()?;
if !seen.insert(invariant.name.clone()) {
return Err(WorkloopSpecError::DuplicateInvariant {
invariant: invariant.name.clone(),
});
}
if arming.is_signal_only() && invariant.tolerance.unconfirmed_for().is_none() {
return Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
invariant: invariant.name.clone(),
});
}
}
Ok(Self {
arming,
invariants,
retention,
carry,
})
}
#[must_use]
pub const fn arming(&self) -> &WorkloopArming {
&self.arming
}
#[must_use]
pub fn invariants(&self) -> &[InvariantSpec] {
&self.invariants
}
#[must_use]
pub const fn carry(&self) -> &CarryContract {
&self.carry
}
#[must_use]
pub const fn retention(&self) -> Duration {
self.retention
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct WorkloopSpecWire {
arming: WorkloopArming,
invariants: Vec<InvariantSpec>,
retention: Duration,
carry: CarryContract,
}
impl TryFrom<WorkloopSpecWire> for WorkloopSpec {
type Error = WorkloopSpecError;
fn try_from(wire: WorkloopSpecWire) -> Result<Self, Self::Error> {
Self::with_carry(wire.arming, wire.invariants, wire.retention, wire.carry)
}
}
impl From<WorkloopSpec> for WorkloopSpecWire {
fn from(spec: WorkloopSpec) -> Self {
Self {
carry: spec.carry,
arming: spec.arming,
invariants: spec.invariants,
retention: spec.retention,
}
}
}
const HATCH_IDENTITY_NAMESPACE: Uuid = Uuid::from_bytes([
0xa1, 0x0f, 0x7a, 0x8e, 0x9d, 0x3c, 0x45, 0xf1, 0x8f, 0x2a, 0x4b, 0x6e, 0x1c, 0x9d, 0x2e, 0x73,
]);
pub fn hatch_workflow_id(
namespace: &str,
workflow_type: &str,
key: &str,
) -> Result<WorkflowId, WorkloopSpecError> {
for part in [namespace, workflow_type, key] {
if part.is_empty() {
return Err(WorkloopSpecError::EmptyHatchIdentityPart);
}
if part.contains('\0') {
return Err(WorkloopSpecError::HatchIdentityNulByte);
}
}
let name = format!("{namespace}\0{workflow_type}\0{key}");
Ok(WorkflowId::new(Uuid::new_v5(
&HATCH_IDENTITY_NAMESPACE,
name.as_bytes(),
)))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::time::Duration;
use super::{
AlarmCause, HealthSample, HealthStatus, InvariantSpec, ToleranceSpec, WorkloopArming,
WorkloopSpec, WorkloopSpecError, hatch_workflow_id, workflow_kind_from_attributes,
};
use crate::SearchAttributeValue;
fn invariant(name: &str, tolerance: ToleranceSpec) -> InvariantSpec {
InvariantSpec {
name: String::from(name),
record_type: String::from("ServeState"),
tolerance,
confirms: vec![String::from("sweep")],
}
}
fn cadence_arming() -> Result<WorkloopArming, WorkloopSpecError> {
WorkloopArming::every(Duration::from_secs(900))
}
#[test]
fn tolerance_requires_a_declared_form() -> Result<(), Box<dyn std::error::Error>> {
let undeclared = serde_json::json!({
"consecutive_windows": null,
"unconfirmed_for": null,
});
let error = serde_json::from_value::<ToleranceSpec>(undeclared)
.err()
.ok_or("both-absent tolerance must refuse to decode")?;
assert!(error.to_string().contains("at least one form"));
Ok(())
}
#[test]
fn tolerance_zero_count_is_a_legitimate_declared_value()
-> Result<(), Box<dyn std::error::Error>> {
let zero = ToleranceSpec::count(0);
assert_eq!(zero.consecutive_windows(), Some(0));
let json = serde_json::to_string(&zero)?;
assert_eq!(serde_json::from_str::<ToleranceSpec>(&json)?, zero);
Ok(())
}
#[test]
fn tolerance_zero_duration_is_refused() {
assert_eq!(
ToleranceSpec::duration(Duration::ZERO),
Err(WorkloopSpecError::ToleranceZeroDuration)
);
assert_eq!(
ToleranceSpec::both(3, Duration::ZERO),
Err(WorkloopSpecError::ToleranceZeroDuration)
);
}
#[test]
fn arming_refuses_zero_period_and_empty_signals() {
assert_eq!(
WorkloopArming::every(Duration::ZERO),
Err(WorkloopSpecError::ZeroCadencePeriod)
);
assert_eq!(
WorkloopArming::signal_only(Vec::new()),
Err(WorkloopSpecError::NoSignals)
);
assert_eq!(
WorkloopArming::signal_only(vec![String::new()]),
Err(WorkloopSpecError::EmptySignalName)
);
}
#[test]
fn arming_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
let arming = WorkloopArming::every_with_signals(
Duration::from_secs(1500),
vec![String::from("drain")],
)?;
let json = serde_json::to_string(&arming)?;
assert_eq!(serde_json::from_str::<WorkloopArming>(&json)?, arming);
let unarmed = serde_json::json!({ "every": null, "signals": [] });
assert!(serde_json::from_value::<WorkloopArming>(unarmed).is_err());
Ok(())
}
#[test]
fn spec_requires_invariants_and_retention() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
WorkloopSpec::new(cadence_arming()?, Vec::new(), Duration::from_secs(1)),
Err(WorkloopSpecError::NoInvariants)
);
assert_eq!(
WorkloopSpec::new(
cadence_arming()?,
vec![invariant("serving", ToleranceSpec::count(3))],
Duration::ZERO,
),
Err(WorkloopSpecError::ZeroRetention)
);
Ok(())
}
#[test]
fn spec_refuses_duplicate_and_degenerate_invariants() -> Result<(), Box<dyn std::error::Error>>
{
let duplicate = WorkloopSpec::new(
cadence_arming()?,
vec![
invariant("serving", ToleranceSpec::count(3)),
invariant("serving", ToleranceSpec::count(1)),
],
Duration::from_secs(86_400),
);
assert_eq!(
duplicate,
Err(WorkloopSpecError::DuplicateInvariant {
invariant: String::from("serving")
})
);
let mut nameless = invariant("serving", ToleranceSpec::count(3));
nameless.name = String::new();
assert_eq!(
WorkloopSpec::new(cadence_arming()?, vec![nameless], Duration::from_secs(1)),
Err(WorkloopSpecError::EmptyInvariantName)
);
let mut untyped = invariant("serving", ToleranceSpec::count(3));
untyped.record_type = String::new();
assert_eq!(
WorkloopSpec::new(cadence_arming()?, vec![untyped], Duration::from_secs(1)),
Err(WorkloopSpecError::MissingRecordType {
invariant: String::from("serving")
})
);
let mut unconfirmable = invariant("serving", ToleranceSpec::count(3));
unconfirmable.confirms = Vec::new();
assert_eq!(
WorkloopSpec::new(
cadence_arming()?,
vec![unconfirmable],
Duration::from_secs(1)
),
Err(WorkloopSpecError::NoConfirmingRoutes {
invariant: String::from("serving")
})
);
Ok(())
}
#[test]
fn signal_only_loop_requires_duration_form_tolerance() -> Result<(), Box<dyn std::error::Error>>
{
let arming = WorkloopArming::signal_only(vec![String::from("task_ready")])?;
assert_eq!(
WorkloopSpec::new(
arming.clone(),
vec![invariant("serving", ToleranceSpec::count(3))],
Duration::from_secs(86_400),
),
Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
invariant: String::from("serving")
})
);
let duration_form = ToleranceSpec::duration(Duration::from_secs(2700))?;
WorkloopSpec::new(
arming.clone(),
vec![invariant("serving", duration_form)],
Duration::from_secs(86_400),
)?;
let both_forms = ToleranceSpec::both(3, Duration::from_secs(2700))?;
WorkloopSpec::new(
arming,
vec![invariant("serving", both_forms)],
Duration::from_secs(86_400),
)?;
Ok(())
}
#[test]
fn spec_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
let spec = WorkloopSpec::new(
cadence_arming()?,
vec![invariant(
"serving",
ToleranceSpec::both(3, Duration::from_secs(2700))?,
)],
Duration::from_secs(14 * 86_400),
)?;
let json = serde_json::to_string(&spec)?;
assert_eq!(serde_json::from_str::<WorkloopSpec>(&json)?, spec);
Ok(())
}
#[test]
fn a_spec_encoding_missing_its_carry_contract_refuses_to_decode()
-> Result<(), Box<dyn std::error::Error>> {
let spec = WorkloopSpec::new(
cadence_arming()?,
vec![invariant("serving", ToleranceSpec::count(3))],
Duration::from_secs(14 * 86_400),
)?;
let mut encoded = serde_json::to_value(&spec)?;
let object = encoded
.as_object_mut()
.ok_or("a workloop spec must encode as a JSON object")?;
assert!(
object.contains_key("carry"),
"fixture control: the encoding must carry the field this test removes, or \
removing it proves nothing: {object:?}"
);
assert_eq!(
serde_json::from_value::<WorkloopSpec>(encoded.clone())?,
spec
);
let object = encoded
.as_object_mut()
.ok_or("a workloop spec must encode as a JSON object")?;
object.remove("carry");
let refusal = serde_json::from_value::<WorkloopSpec>(encoded)
.err()
.ok_or("a spec encoding with no carry contract must not decode")?;
assert!(
refusal.to_string().contains("carry"),
"the refusal must NAME the missing field so an operator knows what is absent: \
{refusal}"
);
Ok(())
}
#[test]
fn alarm_causes_serialize_as_kebab_case_vocabulary() -> Result<(), serde_json::Error> {
for (cause, wire) in [
(AlarmCause::SampleRed, "\"sample-red\""),
(AlarmCause::WindowMissed, "\"window-missed\""),
(AlarmCause::LoopDead, "\"loop-dead\""),
(AlarmCause::UnconfirmedUnknown, "\"unconfirmed-unknown\""),
] {
assert_eq!(serde_json::to_string(&cause)?, wire);
assert_eq!(serde_json::from_str::<AlarmCause>(wire)?, cause);
}
Ok(())
}
#[test]
fn health_samples_round_trip_through_json() -> Result<(), serde_json::Error> {
for sample in [
HealthSample {
invariant: String::from("serving"),
status: HealthStatus::Confirmed,
window_seq: Some(41),
},
HealthSample {
invariant: String::from("serving"),
status: HealthStatus::Unconfirmed,
window_seq: None,
},
] {
let json = serde_json::to_string(&sample)?;
assert_eq!(serde_json::from_str::<HealthSample>(&json)?, sample);
}
Ok(())
}
#[test]
fn hatch_identity_is_deterministic_and_discriminating() -> Result<(), Box<dyn std::error::Error>>
{
let first = hatch_workflow_id("default", "process_task", "task-42")?;
let again = hatch_workflow_id("default", "process_task", "task-42")?;
assert_eq!(first, again);
assert_ne!(
first,
hatch_workflow_id("other", "process_task", "task-42")?
);
assert_ne!(
first,
hatch_workflow_id("default", "other_task", "task-42")?
);
assert_ne!(
first,
hatch_workflow_id("default", "process_task", "task-43")?
);
assert_ne!(
hatch_workflow_id("a", "bc", "d")?,
hatch_workflow_id("ab", "c", "d")?
);
Ok(())
}
#[test]
fn hatch_identity_refuses_empty_and_nul_parts() {
assert_eq!(
hatch_workflow_id("", "process_task", "task-42"),
Err(WorkloopSpecError::EmptyHatchIdentityPart)
);
assert_eq!(
hatch_workflow_id("default", "", "task-42"),
Err(WorkloopSpecError::EmptyHatchIdentityPart)
);
assert_eq!(
hatch_workflow_id("default", "process_task", ""),
Err(WorkloopSpecError::EmptyHatchIdentityPart)
);
assert_eq!(
hatch_workflow_id("default", "process\0task", "task-42"),
Err(WorkloopSpecError::HatchIdentityNulByte)
);
}
#[test]
fn workflow_kind_projects_from_attributes() {
let mut attributes = HashMap::new();
assert_eq!(workflow_kind_from_attributes(&attributes), None);
attributes.insert(
String::from(super::WORKFLOW_KIND_ATTRIBUTE),
SearchAttributeValue::String(String::from(super::WORKLOOP_KIND)),
);
assert_eq!(
workflow_kind_from_attributes(&attributes),
Some(String::from("workloop"))
);
attributes.insert(
String::from(super::WORKFLOW_KIND_ATTRIBUTE),
SearchAttributeValue::Int(7),
);
assert_eq!(workflow_kind_from_attributes(&attributes), None);
}
}