1use std::collections::BTreeMap;
11use std::time::Duration;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::WorkflowId;
18
19pub const WORKFLOW_KIND_ATTRIBUTE: &str = "aion.kind";
28
29pub const WORKLOOP_KIND: &str = "workloop";
31
32#[must_use]
38pub fn workflow_kind_from_attributes<S: std::hash::BuildHasher>(
39 attributes: &std::collections::HashMap<String, crate::SearchAttributeValue, S>,
40) -> Option<String> {
41 match attributes.get(WORKFLOW_KIND_ATTRIBUTE) {
42 Some(crate::SearchAttributeValue::String(kind)) => Some(kind.clone()),
43 _ => None,
44 }
45}
46
47#[must_use]
52pub fn workflow_kind(events: &[crate::Event]) -> Option<String> {
53 workflow_kind_from_attributes(&crate::search_attributes_from_events(events))
54}
55
56#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
64#[serde(rename_all = "kebab-case")]
65pub enum AlarmCause {
66 SampleRed,
68 WindowMissed,
73 LoopDead,
77 UnconfirmedUnknown,
82}
83
84#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
86pub enum HealthStatus {
87 Confirmed,
89 Unconfirmed,
93}
94
95#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
97pub struct HealthSample {
98 pub invariant: String,
100 pub status: HealthStatus,
102 pub window_seq: Option<u64>,
105}
106
107#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
111pub struct InvariantAlarm {
112 pub invariant: String,
114 pub cause: AlarmCause,
116 pub window_seq: Option<u64>,
119 pub last_confirmed_at: Option<DateTime<Utc>>,
121 pub consecutive_unconfirmed: u64,
123}
124
125#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
131pub enum WorkloopSpecError {
132 #[error("tolerance must declare at least one form (N windows or unconfirmed-for duration)")]
134 ToleranceUndeclared,
135 #[error("tolerance duration must be greater than zero")]
137 ToleranceZeroDuration,
138 #[error("cadence period must be greater than zero")]
140 ZeroCadencePeriod,
141 #[error("signal arming must name at least one signal")]
143 NoSignals,
144 #[error("signal names must be non-empty")]
146 EmptySignalName,
147 #[error(
150 "invariant `{invariant}` on a signal-only workloop must declare the duration-form \
151 tolerance: with no windows to miss, a count of samples can never alarm on total silence"
152 )]
153 SignalOnlyNeedsDurationTolerance {
154 invariant: String,
156 },
157 #[error("invariant names must be non-empty")]
159 EmptyInvariantName,
160 #[error("invariant `{invariant}` is declared more than once")]
162 DuplicateInvariant {
163 invariant: String,
165 },
166 #[error("invariant `{invariant}` must declare the type of its current-state record")]
168 MissingRecordType {
169 invariant: String,
171 },
172 #[error(
174 "invariant `{invariant}` must declare at least one confirming route; an invariant \
175 nothing can confirm alarms unconditionally"
176 )]
177 NoConfirmingRoutes {
178 invariant: String,
180 },
181 #[error("confirming route names on invariant `{invariant}` must be non-empty")]
183 EmptyConfirmingRoute {
184 invariant: String,
186 },
187 #[error(
189 "a workloop must declare at least one invariant; a loop with no invariants has no \
190 health surface and rebuilds the silent-death family"
191 )]
192 NoInvariants,
193 #[error("retention window must be greater than zero")]
195 ZeroRetention,
196 #[error(
199 "retention window of {seconds}s cannot be expressed as a calendar duration, so no \
200 retention cutoff can be derived from it; declare a window the clock can subtract"
201 )]
202 UnrepresentableRetention {
203 seconds: u64,
205 },
206 #[error("carry field names must be non-empty")]
208 EmptyCarryField,
209 #[error("a workloop start payload carrying declared carry fields must be a JSON document")]
211 CarrySeedTargetNotJson,
212 #[error(
214 "a workloop start payload must be a JSON object so declared carry fields can be seeded \
215 into it; seeding a scalar or an array has nowhere to put a named field"
216 )]
217 CarrySeedTargetNotAnObject,
218 #[error("hatch identity parts (namespace, workflow type, key) must be non-empty")]
220 EmptyHatchIdentityPart,
221 #[error("hatch identity parts must not contain NUL bytes")]
224 HatchIdentityNulByte,
225}
226
227#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
235#[serde(try_from = "ToleranceSpecWire", into = "ToleranceSpecWire")]
236pub struct ToleranceSpec {
237 consecutive_windows: Option<u64>,
238 unconfirmed_for: Option<Duration>,
239}
240
241impl ToleranceSpec {
242 #[must_use]
247 pub const fn count(windows: u64) -> Self {
248 Self {
249 consecutive_windows: Some(windows),
250 unconfirmed_for: None,
251 }
252 }
253
254 pub const fn duration(unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
262 if unconfirmed_for.is_zero() {
263 return Err(WorkloopSpecError::ToleranceZeroDuration);
264 }
265 Ok(Self {
266 consecutive_windows: None,
267 unconfirmed_for: Some(unconfirmed_for),
268 })
269 }
270
271 pub const fn both(windows: u64, unconfirmed_for: Duration) -> Result<Self, WorkloopSpecError> {
278 if unconfirmed_for.is_zero() {
279 return Err(WorkloopSpecError::ToleranceZeroDuration);
280 }
281 Ok(Self {
282 consecutive_windows: Some(windows),
283 unconfirmed_for: Some(unconfirmed_for),
284 })
285 }
286
287 #[must_use]
289 pub const fn consecutive_windows(&self) -> Option<u64> {
290 self.consecutive_windows
291 }
292
293 #[must_use]
295 pub const fn unconfirmed_for(&self) -> Option<Duration> {
296 self.unconfirmed_for
297 }
298}
299
300#[derive(Serialize, Deserialize, Clone, Debug)]
303struct ToleranceSpecWire {
304 consecutive_windows: Option<u64>,
305 unconfirmed_for: Option<Duration>,
306}
307
308impl TryFrom<ToleranceSpecWire> for ToleranceSpec {
309 type Error = WorkloopSpecError;
310
311 fn try_from(wire: ToleranceSpecWire) -> Result<Self, Self::Error> {
312 match (wire.consecutive_windows, wire.unconfirmed_for) {
313 (None, None) => Err(WorkloopSpecError::ToleranceUndeclared),
314 (Some(windows), None) => Ok(Self::count(windows)),
315 (None, Some(duration)) => Self::duration(duration),
316 (Some(windows), Some(duration)) => Self::both(windows, duration),
317 }
318 }
319}
320
321impl From<ToleranceSpec> for ToleranceSpecWire {
322 fn from(spec: ToleranceSpec) -> Self {
323 Self {
324 consecutive_windows: spec.consecutive_windows,
325 unconfirmed_for: spec.unconfirmed_for,
326 }
327 }
328}
329
330#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
334#[serde(try_from = "WorkloopArmingWire", into = "WorkloopArmingWire")]
335pub struct WorkloopArming {
336 every: Option<Duration>,
337 signals: Vec<String>,
338}
339
340impl WorkloopArming {
341 pub fn every(period: Duration) -> Result<Self, WorkloopSpecError> {
347 if period.is_zero() {
348 return Err(WorkloopSpecError::ZeroCadencePeriod);
349 }
350 Ok(Self {
351 every: Some(period),
352 signals: Vec::new(),
353 })
354 }
355
356 pub fn every_with_signals(
362 period: Duration,
363 signals: Vec<String>,
364 ) -> Result<Self, WorkloopSpecError> {
365 if period.is_zero() {
366 return Err(WorkloopSpecError::ZeroCadencePeriod);
367 }
368 validate_signals(&signals, false)?;
369 Ok(Self {
370 every: Some(period),
371 signals,
372 })
373 }
374
375 pub fn signal_only(signals: Vec<String>) -> Result<Self, WorkloopSpecError> {
384 validate_signals(&signals, true)?;
385 Ok(Self {
386 every: None,
387 signals,
388 })
389 }
390
391 #[must_use]
393 pub const fn cadence_period(&self) -> Option<Duration> {
394 self.every
395 }
396
397 #[must_use]
399 pub fn signals(&self) -> &[String] {
400 &self.signals
401 }
402
403 #[must_use]
405 pub const fn is_signal_only(&self) -> bool {
406 self.every.is_none()
407 }
408}
409
410fn validate_signals(signals: &[String], require_nonempty: bool) -> Result<(), WorkloopSpecError> {
411 if require_nonempty && signals.is_empty() {
412 return Err(WorkloopSpecError::NoSignals);
413 }
414 if signals.iter().any(String::is_empty) {
415 return Err(WorkloopSpecError::EmptySignalName);
416 }
417 Ok(())
418}
419
420#[derive(Serialize, Deserialize, Clone, Debug)]
423struct WorkloopArmingWire {
424 every: Option<Duration>,
425 signals: Vec<String>,
426}
427
428impl TryFrom<WorkloopArmingWire> for WorkloopArming {
429 type Error = WorkloopSpecError;
430
431 fn try_from(wire: WorkloopArmingWire) -> Result<Self, Self::Error> {
432 match wire.every {
433 Some(period) if wire.signals.is_empty() => Self::every(period),
434 Some(period) => Self::every_with_signals(period, wire.signals),
435 None => Self::signal_only(wire.signals),
436 }
437 }
438}
439
440impl From<WorkloopArming> for WorkloopArmingWire {
441 fn from(arming: WorkloopArming) -> Self {
442 Self {
443 every: arming.every,
444 signals: arming.signals,
445 }
446 }
447}
448
449#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
452pub struct InvariantSpec {
453 pub name: String,
455 pub record_type: String,
459 pub tolerance: ToleranceSpec,
461 pub confirms: Vec<String>,
463}
464
465impl InvariantSpec {
466 fn validate(&self) -> Result<(), WorkloopSpecError> {
467 if self.name.is_empty() {
468 return Err(WorkloopSpecError::EmptyInvariantName);
469 }
470 if self.record_type.is_empty() {
471 return Err(WorkloopSpecError::MissingRecordType {
472 invariant: self.name.clone(),
473 });
474 }
475 if self.confirms.is_empty() {
476 return Err(WorkloopSpecError::NoConfirmingRoutes {
477 invariant: self.name.clone(),
478 });
479 }
480 if self.confirms.iter().any(String::is_empty) {
481 return Err(WorkloopSpecError::EmptyConfirmingRoute {
482 invariant: self.name.clone(),
483 });
484 }
485 Ok(())
486 }
487}
488
489#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
512#[serde(try_from = "CarryContractWire", into = "CarryContractWire")]
513pub struct CarryContract {
514 defaults: BTreeMap<String, serde_json::Value>,
515}
516
517impl CarryContract {
518 #[must_use]
520 pub fn none() -> Self {
521 Self::default()
522 }
523
524 pub fn new(defaults: BTreeMap<String, serde_json::Value>) -> Result<Self, WorkloopSpecError> {
530 if defaults.keys().any(String::is_empty) {
531 return Err(WorkloopSpecError::EmptyCarryField);
532 }
533 Ok(Self { defaults })
534 }
535
536 #[must_use]
538 pub const fn defaults(&self) -> &BTreeMap<String, serde_json::Value> {
539 &self.defaults
540 }
541
542 #[must_use]
544 pub fn is_empty(&self) -> bool {
545 self.defaults.is_empty()
546 }
547
548 pub fn seed(&self, input: &crate::Payload) -> Result<crate::Payload, WorkloopSpecError> {
560 if self.defaults.is_empty() {
561 return Ok(input.clone());
562 }
563 let mut document: serde_json::Value = serde_json::from_slice(input.bytes())
564 .map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
565 let object = document
566 .as_object_mut()
567 .ok_or(WorkloopSpecError::CarrySeedTargetNotAnObject)?;
568 for (field, default) in &self.defaults {
569 if !object.contains_key(field) {
570 object.insert(field.clone(), default.clone());
571 }
572 }
573 let bytes =
574 serde_json::to_vec(&document).map_err(|_| WorkloopSpecError::CarrySeedTargetNotJson)?;
575 Ok(crate::Payload::new(crate::ContentType::Json, bytes))
576 }
577}
578
579#[derive(Serialize, Deserialize, Clone, Debug)]
581struct CarryContractWire {
582 defaults: BTreeMap<String, serde_json::Value>,
583}
584
585impl TryFrom<CarryContractWire> for CarryContract {
586 type Error = WorkloopSpecError;
587
588 fn try_from(wire: CarryContractWire) -> Result<Self, Self::Error> {
589 Self::new(wire.defaults)
590 }
591}
592
593impl From<CarryContract> for CarryContractWire {
594 fn from(contract: CarryContract) -> Self {
595 Self {
596 defaults: contract.defaults,
597 }
598 }
599}
600
601#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
605#[serde(try_from = "WorkloopSpecWire", into = "WorkloopSpecWire")]
606pub struct WorkloopSpec {
607 arming: WorkloopArming,
608 invariants: Vec<InvariantSpec>,
609 retention: Duration,
610 carry: CarryContract,
611}
612
613impl WorkloopSpec {
614 pub fn new(
623 arming: WorkloopArming,
624 invariants: Vec<InvariantSpec>,
625 retention: Duration,
626 ) -> Result<Self, WorkloopSpecError> {
627 Self::with_carry(arming, invariants, retention, CarryContract::none())
628 }
629
630 pub fn with_carry(
637 arming: WorkloopArming,
638 invariants: Vec<InvariantSpec>,
639 retention: Duration,
640 carry: CarryContract,
641 ) -> Result<Self, WorkloopSpecError> {
642 if invariants.is_empty() {
643 return Err(WorkloopSpecError::NoInvariants);
644 }
645 if retention.is_zero() {
646 return Err(WorkloopSpecError::ZeroRetention);
647 }
648 if chrono::Duration::from_std(retention).is_err() {
659 return Err(WorkloopSpecError::UnrepresentableRetention {
660 seconds: retention.as_secs(),
661 });
662 }
663 let mut seen = std::collections::HashSet::new();
664 for invariant in &invariants {
665 invariant.validate()?;
666 if !seen.insert(invariant.name.clone()) {
667 return Err(WorkloopSpecError::DuplicateInvariant {
668 invariant: invariant.name.clone(),
669 });
670 }
671 if arming.is_signal_only() && invariant.tolerance.unconfirmed_for().is_none() {
672 return Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
673 invariant: invariant.name.clone(),
674 });
675 }
676 }
677 Ok(Self {
678 arming,
679 invariants,
680 retention,
681 carry,
682 })
683 }
684
685 #[must_use]
687 pub const fn arming(&self) -> &WorkloopArming {
688 &self.arming
689 }
690
691 #[must_use]
693 pub fn invariants(&self) -> &[InvariantSpec] {
694 &self.invariants
695 }
696
697 #[must_use]
699 pub const fn carry(&self) -> &CarryContract {
700 &self.carry
701 }
702
703 #[must_use]
707 pub const fn retention(&self) -> Duration {
708 self.retention
709 }
710}
711
712#[derive(Serialize, Deserialize, Clone, Debug)]
727struct WorkloopSpecWire {
728 arming: WorkloopArming,
729 invariants: Vec<InvariantSpec>,
730 retention: Duration,
731 carry: CarryContract,
732}
733
734impl TryFrom<WorkloopSpecWire> for WorkloopSpec {
735 type Error = WorkloopSpecError;
736
737 fn try_from(wire: WorkloopSpecWire) -> Result<Self, Self::Error> {
738 Self::with_carry(wire.arming, wire.invariants, wire.retention, wire.carry)
739 }
740}
741
742impl From<WorkloopSpec> for WorkloopSpecWire {
743 fn from(spec: WorkloopSpec) -> Self {
744 Self {
745 carry: spec.carry,
746 arming: spec.arming,
747 invariants: spec.invariants,
748 retention: spec.retention,
749 }
750 }
751}
752
753const HATCH_IDENTITY_NAMESPACE: Uuid = Uuid::from_bytes([
759 0xa1, 0x0f, 0x7a, 0x8e, 0x9d, 0x3c, 0x45, 0xf1, 0x8f, 0x2a, 0x4b, 0x6e, 0x1c, 0x9d, 0x2e, 0x73,
760]);
761
762pub fn hatch_workflow_id(
775 namespace: &str,
776 workflow_type: &str,
777 key: &str,
778) -> Result<WorkflowId, WorkloopSpecError> {
779 for part in [namespace, workflow_type, key] {
780 if part.is_empty() {
781 return Err(WorkloopSpecError::EmptyHatchIdentityPart);
782 }
783 if part.contains('\0') {
784 return Err(WorkloopSpecError::HatchIdentityNulByte);
785 }
786 }
787 let name = format!("{namespace}\0{workflow_type}\0{key}");
788 Ok(WorkflowId::new(Uuid::new_v5(
789 &HATCH_IDENTITY_NAMESPACE,
790 name.as_bytes(),
791 )))
792}
793
794#[cfg(test)]
795mod tests {
796 use std::collections::HashMap;
797 use std::time::Duration;
798
799 use super::{
800 AlarmCause, HealthSample, HealthStatus, InvariantSpec, ToleranceSpec, WorkloopArming,
801 WorkloopSpec, WorkloopSpecError, hatch_workflow_id, workflow_kind_from_attributes,
802 };
803 use crate::SearchAttributeValue;
804
805 fn invariant(name: &str, tolerance: ToleranceSpec) -> InvariantSpec {
806 InvariantSpec {
807 name: String::from(name),
808 record_type: String::from("ServeState"),
809 tolerance,
810 confirms: vec![String::from("sweep")],
811 }
812 }
813
814 fn cadence_arming() -> Result<WorkloopArming, WorkloopSpecError> {
815 WorkloopArming::every(Duration::from_secs(900))
816 }
817
818 #[test]
819 fn tolerance_requires_a_declared_form() -> Result<(), Box<dyn std::error::Error>> {
820 let undeclared = serde_json::json!({
821 "consecutive_windows": null,
822 "unconfirmed_for": null,
823 });
824 let error = serde_json::from_value::<ToleranceSpec>(undeclared)
825 .err()
826 .ok_or("both-absent tolerance must refuse to decode")?;
827 assert!(error.to_string().contains("at least one form"));
828 Ok(())
829 }
830
831 #[test]
832 fn tolerance_zero_count_is_a_legitimate_declared_value()
833 -> Result<(), Box<dyn std::error::Error>> {
834 let zero = ToleranceSpec::count(0);
835 assert_eq!(zero.consecutive_windows(), Some(0));
836 let json = serde_json::to_string(&zero)?;
837 assert_eq!(serde_json::from_str::<ToleranceSpec>(&json)?, zero);
838 Ok(())
839 }
840
841 #[test]
842 fn tolerance_zero_duration_is_refused() {
843 assert_eq!(
844 ToleranceSpec::duration(Duration::ZERO),
845 Err(WorkloopSpecError::ToleranceZeroDuration)
846 );
847 assert_eq!(
848 ToleranceSpec::both(3, Duration::ZERO),
849 Err(WorkloopSpecError::ToleranceZeroDuration)
850 );
851 }
852
853 #[test]
854 fn arming_refuses_zero_period_and_empty_signals() {
855 assert_eq!(
856 WorkloopArming::every(Duration::ZERO),
857 Err(WorkloopSpecError::ZeroCadencePeriod)
858 );
859 assert_eq!(
860 WorkloopArming::signal_only(Vec::new()),
861 Err(WorkloopSpecError::NoSignals)
862 );
863 assert_eq!(
864 WorkloopArming::signal_only(vec![String::new()]),
865 Err(WorkloopSpecError::EmptySignalName)
866 );
867 }
868
869 #[test]
870 fn arming_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
871 let arming = WorkloopArming::every_with_signals(
872 Duration::from_secs(1500),
873 vec![String::from("drain")],
874 )?;
875 let json = serde_json::to_string(&arming)?;
876 assert_eq!(serde_json::from_str::<WorkloopArming>(&json)?, arming);
877
878 let unarmed = serde_json::json!({ "every": null, "signals": [] });
879 assert!(serde_json::from_value::<WorkloopArming>(unarmed).is_err());
880 Ok(())
881 }
882
883 #[test]
884 fn spec_requires_invariants_and_retention() -> Result<(), Box<dyn std::error::Error>> {
885 assert_eq!(
886 WorkloopSpec::new(cadence_arming()?, Vec::new(), Duration::from_secs(1)),
887 Err(WorkloopSpecError::NoInvariants)
888 );
889 assert_eq!(
890 WorkloopSpec::new(
891 cadence_arming()?,
892 vec![invariant("serving", ToleranceSpec::count(3))],
893 Duration::ZERO,
894 ),
895 Err(WorkloopSpecError::ZeroRetention)
896 );
897 Ok(())
898 }
899
900 #[test]
901 fn spec_refuses_duplicate_and_degenerate_invariants() -> Result<(), Box<dyn std::error::Error>>
902 {
903 let duplicate = WorkloopSpec::new(
904 cadence_arming()?,
905 vec![
906 invariant("serving", ToleranceSpec::count(3)),
907 invariant("serving", ToleranceSpec::count(1)),
908 ],
909 Duration::from_secs(86_400),
910 );
911 assert_eq!(
912 duplicate,
913 Err(WorkloopSpecError::DuplicateInvariant {
914 invariant: String::from("serving")
915 })
916 );
917
918 let mut nameless = invariant("serving", ToleranceSpec::count(3));
919 nameless.name = String::new();
920 assert_eq!(
921 WorkloopSpec::new(cadence_arming()?, vec![nameless], Duration::from_secs(1)),
922 Err(WorkloopSpecError::EmptyInvariantName)
923 );
924
925 let mut untyped = invariant("serving", ToleranceSpec::count(3));
926 untyped.record_type = String::new();
927 assert_eq!(
928 WorkloopSpec::new(cadence_arming()?, vec![untyped], Duration::from_secs(1)),
929 Err(WorkloopSpecError::MissingRecordType {
930 invariant: String::from("serving")
931 })
932 );
933
934 let mut unconfirmable = invariant("serving", ToleranceSpec::count(3));
935 unconfirmable.confirms = Vec::new();
936 assert_eq!(
937 WorkloopSpec::new(
938 cadence_arming()?,
939 vec![unconfirmable],
940 Duration::from_secs(1)
941 ),
942 Err(WorkloopSpecError::NoConfirmingRoutes {
943 invariant: String::from("serving")
944 })
945 );
946 Ok(())
947 }
948
949 #[test]
950 fn signal_only_loop_requires_duration_form_tolerance() -> Result<(), Box<dyn std::error::Error>>
951 {
952 let arming = WorkloopArming::signal_only(vec![String::from("task_ready")])?;
953
954 assert_eq!(
956 WorkloopSpec::new(
957 arming.clone(),
958 vec![invariant("serving", ToleranceSpec::count(3))],
959 Duration::from_secs(86_400),
960 ),
961 Err(WorkloopSpecError::SignalOnlyNeedsDurationTolerance {
962 invariant: String::from("serving")
963 })
964 );
965
966 let duration_form = ToleranceSpec::duration(Duration::from_secs(2700))?;
968 WorkloopSpec::new(
969 arming.clone(),
970 vec![invariant("serving", duration_form)],
971 Duration::from_secs(86_400),
972 )?;
973 let both_forms = ToleranceSpec::both(3, Duration::from_secs(2700))?;
974 WorkloopSpec::new(
975 arming,
976 vec![invariant("serving", both_forms)],
977 Duration::from_secs(86_400),
978 )?;
979 Ok(())
980 }
981
982 #[test]
983 fn spec_round_trips_and_revalidates_on_decode() -> Result<(), Box<dyn std::error::Error>> {
984 let spec = WorkloopSpec::new(
985 cadence_arming()?,
986 vec![invariant(
987 "serving",
988 ToleranceSpec::both(3, Duration::from_secs(2700))?,
989 )],
990 Duration::from_secs(14 * 86_400),
991 )?;
992 let json = serde_json::to_string(&spec)?;
993 assert_eq!(serde_json::from_str::<WorkloopSpec>(&json)?, spec);
994 Ok(())
995 }
996
997 #[test]
1010 fn a_spec_encoding_missing_its_carry_contract_refuses_to_decode()
1011 -> Result<(), Box<dyn std::error::Error>> {
1012 let spec = WorkloopSpec::new(
1013 cadence_arming()?,
1014 vec![invariant("serving", ToleranceSpec::count(3))],
1015 Duration::from_secs(14 * 86_400),
1016 )?;
1017 let mut encoded = serde_json::to_value(&spec)?;
1018 let object = encoded
1019 .as_object_mut()
1020 .ok_or("a workloop spec must encode as a JSON object")?;
1021 assert!(
1022 object.contains_key("carry"),
1023 "fixture control: the encoding must carry the field this test removes, or \
1024 removing it proves nothing: {object:?}"
1025 );
1026
1027 assert_eq!(
1029 serde_json::from_value::<WorkloopSpec>(encoded.clone())?,
1030 spec
1031 );
1032
1033 let object = encoded
1034 .as_object_mut()
1035 .ok_or("a workloop spec must encode as a JSON object")?;
1036 object.remove("carry");
1037 let refusal = serde_json::from_value::<WorkloopSpec>(encoded)
1038 .err()
1039 .ok_or("a spec encoding with no carry contract must not decode")?;
1040 assert!(
1041 refusal.to_string().contains("carry"),
1042 "the refusal must NAME the missing field so an operator knows what is absent: \
1043 {refusal}"
1044 );
1045 Ok(())
1046 }
1047
1048 #[test]
1049 fn alarm_causes_serialize_as_kebab_case_vocabulary() -> Result<(), serde_json::Error> {
1050 for (cause, wire) in [
1051 (AlarmCause::SampleRed, "\"sample-red\""),
1052 (AlarmCause::WindowMissed, "\"window-missed\""),
1053 (AlarmCause::LoopDead, "\"loop-dead\""),
1054 (AlarmCause::UnconfirmedUnknown, "\"unconfirmed-unknown\""),
1055 ] {
1056 assert_eq!(serde_json::to_string(&cause)?, wire);
1057 assert_eq!(serde_json::from_str::<AlarmCause>(wire)?, cause);
1058 }
1059 Ok(())
1060 }
1061
1062 #[test]
1063 fn health_samples_round_trip_through_json() -> Result<(), serde_json::Error> {
1064 for sample in [
1065 HealthSample {
1066 invariant: String::from("serving"),
1067 status: HealthStatus::Confirmed,
1068 window_seq: Some(41),
1069 },
1070 HealthSample {
1071 invariant: String::from("serving"),
1072 status: HealthStatus::Unconfirmed,
1073 window_seq: None,
1074 },
1075 ] {
1076 let json = serde_json::to_string(&sample)?;
1077 assert_eq!(serde_json::from_str::<HealthSample>(&json)?, sample);
1078 }
1079 Ok(())
1080 }
1081
1082 #[test]
1083 fn hatch_identity_is_deterministic_and_discriminating() -> Result<(), Box<dyn std::error::Error>>
1084 {
1085 let first = hatch_workflow_id("default", "process_task", "task-42")?;
1086 let again = hatch_workflow_id("default", "process_task", "task-42")?;
1087 assert_eq!(first, again);
1088
1089 assert_ne!(
1091 first,
1092 hatch_workflow_id("other", "process_task", "task-42")?
1093 );
1094 assert_ne!(
1095 first,
1096 hatch_workflow_id("default", "other_task", "task-42")?
1097 );
1098 assert_ne!(
1099 first,
1100 hatch_workflow_id("default", "process_task", "task-43")?
1101 );
1102
1103 assert_ne!(
1105 hatch_workflow_id("a", "bc", "d")?,
1106 hatch_workflow_id("ab", "c", "d")?
1107 );
1108 Ok(())
1109 }
1110
1111 #[test]
1112 fn hatch_identity_refuses_empty_and_nul_parts() {
1113 assert_eq!(
1114 hatch_workflow_id("", "process_task", "task-42"),
1115 Err(WorkloopSpecError::EmptyHatchIdentityPart)
1116 );
1117 assert_eq!(
1118 hatch_workflow_id("default", "", "task-42"),
1119 Err(WorkloopSpecError::EmptyHatchIdentityPart)
1120 );
1121 assert_eq!(
1122 hatch_workflow_id("default", "process_task", ""),
1123 Err(WorkloopSpecError::EmptyHatchIdentityPart)
1124 );
1125 assert_eq!(
1126 hatch_workflow_id("default", "process\0task", "task-42"),
1127 Err(WorkloopSpecError::HatchIdentityNulByte)
1128 );
1129 }
1130
1131 #[test]
1132 fn workflow_kind_projects_from_attributes() {
1133 let mut attributes = HashMap::new();
1134 assert_eq!(workflow_kind_from_attributes(&attributes), None);
1135 attributes.insert(
1136 String::from(super::WORKFLOW_KIND_ATTRIBUTE),
1137 SearchAttributeValue::String(String::from(super::WORKLOOP_KIND)),
1138 );
1139 assert_eq!(
1140 workflow_kind_from_attributes(&attributes),
1141 Some(String::from("workloop"))
1142 );
1143 attributes.insert(
1144 String::from(super::WORKFLOW_KIND_ATTRIBUTE),
1145 SearchAttributeValue::Int(7),
1146 );
1147 assert_eq!(workflow_kind_from_attributes(&attributes), None);
1148 }
1149}