1use serde_json::Value as JsonValue;
92use std::collections::BTreeMap;
93use tatara_lisp::read_spanned;
94use tatara_lisp_eval::{
95 install_full_stdlib_with, Arity, EvalError, Interpreter, Value as LispValue,
96};
97use thiserror::Error;
98use vigy_types::{Condition, ConditionStatus, ReconcileAction, ReconcileKind};
99
100pub use tatara_lisp::Span;
105pub use tatara_lisp_eval::{
106 Arity as ArityRe, EvalError as EvalErrorRe, Interpreter as InterpreterRe, Value as LispValueRe,
107};
108pub type ExtArity = Arity;
111pub type ExtValue = LispValue;
114pub type ExtInterpreter = Interpreter<VigyHost>;
117pub type ExtEvalError = EvalError;
120
121#[derive(Debug, Error)]
122pub enum EvalErr {
123 #[error("parse: {0}")]
124 Parse(String),
125 #[error("eval: {0}")]
126 Eval(String),
127}
128
129pub type Result<T> = std::result::Result<T, EvalErr>;
130
131#[derive(Debug, Default)]
134pub struct VigyHost {
135 pub tick_start_ms: i64,
137 pub previous_tick_ms: Option<i64>,
138 pub tick_count: i64,
139
140 pub actions: Vec<ReconcileAction>,
142 pub log: Vec<LogEntry>,
143 pub trace: BTreeMap<String, JsonValue>,
144 pub metrics: BTreeMap<String, f64>,
145 pub events: Vec<HostEvent>,
146
147 pub desired: BTreeMap<String, JsonValue>,
149 pub observed: BTreeMap<String, JsonValue>,
150 pub conditions: Vec<Condition>,
151
152 pub kv: BTreeMap<String, JsonValue>,
154 pub kv_dirty: std::collections::BTreeSet<String>,
155 pub kv_deleted: std::collections::BTreeSet<String>,
156}
157
158#[derive(Debug, Clone)]
159pub struct LogEntry {
160 pub level: LogLevel,
161 pub message: String,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum LogLevel {
166 Trace,
167 Debug,
168 Info,
169 Warn,
170 Error,
171}
172
173impl LogLevel {
174 fn parse(s: &str) -> Option<Self> {
175 Some(match s {
176 "trace" => Self::Trace,
177 "debug" => Self::Debug,
178 "info" => Self::Info,
179 "warn" => Self::Warn,
180 "error" => Self::Error,
181 _ => return None,
182 })
183 }
184}
185
186#[derive(Debug, Clone)]
187pub struct HostEvent {
188 pub kind: String,
189 pub message: String,
190}
191
192pub fn evaluate(program: &str, host: VigyHost) -> Result<VigyHost> {
197 evaluate_with_extensions(program, host, &standard_extensions())
198}
199
200pub fn evaluate_with_extensions(
206 program: &str,
207 mut host: VigyHost,
208 extensions: &[ExtensionHandle],
209) -> Result<VigyHost> {
210 let mut interp: Interpreter<VigyHost> = Interpreter::new();
211 install_full_stdlib_with(&mut interp, &mut host);
212 for ext in extensions {
213 ext.install(&mut interp);
214 }
215 let forms = read_spanned(program).map_err(|e| EvalErr::Parse(format!("{e}")))?;
216 interp
217 .eval_program(&forms, &mut host)
218 .map_err(|e| EvalErr::Eval(format!("{e}")))?;
219 Ok(host)
220}
221
222pub fn install_vigy_intrinsics(interp: &mut Interpreter<VigyHost>) {
227 install_action_intrinsics(interp);
228 install_state_intrinsics(interp);
229 install_kv_intrinsics(interp);
230 install_convergence_intrinsics(interp);
231 install_scheduling_intrinsics(interp);
232 install_diagnostic_intrinsics(interp);
233}
234
235pub trait HostExtension: Send + Sync {
249 fn install(&self, interp: &mut Interpreter<VigyHost>);
255}
256
257pub type ExtensionHandle = std::sync::Arc<dyn HostExtension>;
262
263pub fn standard_extensions() -> Vec<ExtensionHandle> {
267 use std::sync::Arc;
268 vec![
269 Arc::new(ActionsExtension),
270 Arc::new(StateExtension),
271 Arc::new(KvExtension),
272 Arc::new(ConvergenceExtension),
273 Arc::new(SchedulingExtension),
274 Arc::new(DiagnosticsExtension),
275 ]
276}
277
278#[async_trait::async_trait]
288pub trait Reconciler: Send + Sync {
289 async fn tick(&self, host: VigyHost) -> Result<VigyHost>;
290}
291
292pub struct LispReconciler {
303 pub program: String,
304 pub extensions: Vec<ExtensionHandle>,
305}
306
307impl LispReconciler {
308 pub fn standard(program: impl Into<String>) -> Self {
309 Self {
310 program: program.into(),
311 extensions: standard_extensions(),
312 }
313 }
314
315 pub fn with_extensions(
316 program: impl Into<String>,
317 extensions: Vec<ExtensionHandle>,
318 ) -> Self {
319 Self {
320 program: program.into(),
321 extensions,
322 }
323 }
324}
325
326#[async_trait::async_trait]
327impl Reconciler for LispReconciler {
328 async fn tick(&self, host: VigyHost) -> Result<VigyHost> {
329 let program = self.program.clone();
333 let extensions = self.extensions.clone();
334 tokio::task::spawn_blocking(move || {
335 evaluate_with_extensions(&program, host, &extensions)
336 })
337 .await
338 .map_err(|e| EvalErr::Eval(format!("join: {e}")))?
339 }
340}
341
342pub struct NoopReconciler;
346
347#[async_trait::async_trait]
348impl Reconciler for NoopReconciler {
349 async fn tick(&self, host: VigyHost) -> Result<VigyHost> {
350 Ok(host)
351 }
352}
353
354pub struct ChainReconciler {
359 pub children: Vec<Box<dyn Reconciler>>,
360}
361
362impl ChainReconciler {
363 pub fn new(children: Vec<Box<dyn Reconciler>>) -> Self {
364 Self { children }
365 }
366}
367
368#[async_trait::async_trait]
369impl Reconciler for ChainReconciler {
370 async fn tick(&self, mut host: VigyHost) -> Result<VigyHost> {
371 for child in &self.children {
372 host = child.tick(host).await?;
373 }
374 Ok(host)
375 }
376}
377
378fn install_action_intrinsics(interp: &mut Interpreter<VigyHost>) {
383 interp.register_fn(
385 "vigy-emit",
386 Arity::AtLeast(1),
387 |args: &[LispValue], host: &mut VigyHost, sp| {
388 if args.is_empty() || args.len() > 2 {
389 return Err(EvalError::native_fn(
390 "vigy-emit",
391 format!("expected 1 or 2 args (kind, payload?), got {}", args.len()),
392 sp,
393 ));
394 }
395 let kind = parse_kind(&lisp_string(&args[0], sp)?, sp, "vigy-emit")?;
396 let payload = if args.len() == 2 {
397 Some(lisp_to_json(&args[1]))
398 } else {
399 None
400 };
401 host.actions.push(ReconcileAction {
402 kind,
403 payload,
404 result: None,
405 message: None,
406 });
407 Ok(LispValue::Nil)
408 },
409 );
410
411 register_sugar(interp, "vigy-noop", Arity::Exact(0), |_, host| {
414 host.actions.push(ReconcileAction::noop());
415 Ok(LispValue::Nil)
416 });
417 register_sugar(interp, "vigy-defer", Arity::Exact(1), |args, host| {
418 host.actions
419 .push(ReconcileAction::defer(args[0].to_display_string()));
420 Ok(LispValue::Nil)
421 });
422 register_sugar(interp, "vigy-pull", Arity::Exact(1), |args, host| {
423 host.actions.push(ReconcileAction::pull(lisp_to_json(&args[0])));
424 Ok(LispValue::Nil)
425 });
426 register_sugar(interp, "vigy-push", Arity::Exact(1), |args, host| {
427 host.actions.push(ReconcileAction::push(lisp_to_json(&args[0])));
428 Ok(LispValue::Nil)
429 });
430 register_sugar(interp, "vigy-create", Arity::Exact(1), |args, host| {
431 host.actions
432 .push(ReconcileAction::create(lisp_to_json(&args[0])));
433 Ok(LispValue::Nil)
434 });
435 register_sugar(interp, "vigy-update", Arity::Exact(1), |args, host| {
436 host.actions
437 .push(ReconcileAction::update(lisp_to_json(&args[0])));
438 Ok(LispValue::Nil)
439 });
440 register_sugar(interp, "vigy-delete-action", Arity::Exact(1), |args, host| {
441 host.actions
445 .push(ReconcileAction::delete(lisp_to_json(&args[0])));
446 Ok(LispValue::Nil)
447 });
448 register_sugar(interp, "vigy-apply", Arity::Exact(1), |args, host| {
449 host.actions
450 .push(ReconcileAction::apply(lisp_to_json(&args[0])));
451 Ok(LispValue::Nil)
452 });
453 register_sugar(interp, "vigy-restart", Arity::Exact(1), |args, host| {
454 host.actions
455 .push(ReconcileAction::restart(lisp_to_json(&args[0])));
456 Ok(LispValue::Nil)
457 });
458}
459
460fn install_state_intrinsics(interp: &mut Interpreter<VigyHost>) {
465 interp.register_fn(
467 "vigy-desired",
468 Arity::Exact(2),
469 |args: &[LispValue], host: &mut VigyHost, sp| {
470 let key = lisp_string(&args[0], sp)?;
471 host.desired.insert(key, lisp_to_json(&args[1]));
472 Ok(LispValue::Nil)
473 },
474 );
475
476 interp.register_fn(
478 "vigy-observed",
479 Arity::Exact(2),
480 |args: &[LispValue], host: &mut VigyHost, sp| {
481 let key = lisp_string(&args[0], sp)?;
482 host.observed.insert(key, lisp_to_json(&args[1]));
483 Ok(LispValue::Nil)
484 },
485 );
486
487 interp.register_fn(
489 "vigy-condition",
490 Arity::AtLeast(2),
491 |args: &[LispValue], host: &mut VigyHost, sp| {
492 if args.len() < 2 || args.len() > 4 {
493 return Err(EvalError::native_fn(
494 "vigy-condition",
495 format!("expected 2..4 args (name, status, reason?, message?), got {}", args.len()),
496 sp,
497 ));
498 }
499 let name = lisp_string(&args[0], sp)?;
500 let status_str = lisp_string(&args[1], sp)?;
501 let status = match status_str.as_str() {
502 "true" | "True" => ConditionStatus::True,
503 "false" | "False" => ConditionStatus::False,
504 "unknown" | "Unknown" => ConditionStatus::Unknown,
505 other => {
506 return Err(EvalError::native_fn(
507 "vigy-condition",
508 format!("status must be true|false|unknown, got {other:?}"),
509 sp,
510 ));
511 }
512 };
513 let reason = if args.len() >= 3 {
514 Some(lisp_string(&args[2], sp)?)
515 } else {
516 None
517 };
518 let message = if args.len() >= 4 {
519 Some(lisp_string(&args[3], sp)?)
520 } else {
521 None
522 };
523 host.conditions.push(Condition {
524 name,
525 status,
526 reason,
527 message,
528 last_transition: time::OffsetDateTime::now_utc(),
529 });
530 Ok(LispValue::Nil)
531 },
532 );
533}
534
535fn install_kv_intrinsics(interp: &mut Interpreter<VigyHost>) {
540 interp.register_fn(
542 "vigy-get",
543 Arity::AtLeast(1),
544 |args: &[LispValue], host: &mut VigyHost, sp| {
545 let key = lisp_string(&args[0], sp)?;
546 match host.kv.get(&key).cloned() {
547 Some(v) => Ok(json_to_lisp(&v)),
548 None if args.len() == 2 => Ok(args[1].clone()),
549 None => Ok(LispValue::Nil),
550 }
551 },
552 );
553
554 interp.register_fn(
556 "vigy-set",
557 Arity::Exact(2),
558 |args: &[LispValue], host: &mut VigyHost, sp| {
559 let key = lisp_string(&args[0], sp)?;
560 let value = lisp_to_json(&args[1]);
561 host.kv.insert(key.clone(), value);
562 host.kv_dirty.insert(key.clone());
563 host.kv_deleted.remove(&key);
564 Ok(LispValue::Nil)
565 },
566 );
567
568 interp.register_fn(
570 "vigy-incr",
571 Arity::AtLeast(1),
572 |args: &[LispValue], host: &mut VigyHost, sp| {
573 let key = lisp_string(&args[0], sp)?;
574 let delta = if args.len() >= 2 {
575 match &args[1] {
576 LispValue::Int(n) => *n,
577 other => {
578 return Err(EvalError::type_mismatch(
579 "integer delta",
580 other.type_name(),
581 sp,
582 ))
583 }
584 }
585 } else {
586 1
587 };
588 let current = host
589 .kv
590 .get(&key)
591 .and_then(|v| v.as_i64())
592 .unwrap_or(0);
593 let next = current.saturating_add(delta);
594 host.kv
595 .insert(key.clone(), JsonValue::Number(next.into()));
596 host.kv_dirty.insert(key.clone());
597 host.kv_deleted.remove(&key);
598 Ok(LispValue::Int(next))
599 },
600 );
601
602 interp.register_fn(
604 "vigy-has?",
605 Arity::Exact(1),
606 |args: &[LispValue], host: &mut VigyHost, sp| {
607 let key = lisp_string(&args[0], sp)?;
608 Ok(LispValue::Bool(host.kv.contains_key(&key)))
609 },
610 );
611
612 interp.register_fn(
614 "vigy-del",
615 Arity::Exact(1),
616 |args: &[LispValue], host: &mut VigyHost, sp| {
617 let key = lisp_string(&args[0], sp)?;
618 let existed = host.kv.remove(&key).is_some();
619 host.kv_dirty.remove(&key);
620 host.kv_deleted.insert(key);
621 Ok(LispValue::Bool(existed))
622 },
623 );
624}
625
626fn install_convergence_intrinsics(interp: &mut Interpreter<VigyHost>) {
631 interp.register_fn(
634 "vigy-once",
635 Arity::Exact(1),
636 |args: &[LispValue], host: &mut VigyHost, sp| {
637 let key = format!("__once::{}", lisp_string(&args[0], sp)?);
638 if host.kv.contains_key(&key) {
639 Ok(LispValue::Bool(false))
640 } else {
641 host.kv.insert(key.clone(), JsonValue::Bool(true));
642 host.kv_dirty.insert(key);
643 Ok(LispValue::Bool(true))
644 }
645 },
646 );
647
648 interp.register_fn(
650 "vigy-mark-converged",
651 Arity::Exact(1),
652 |args: &[LispValue], host: &mut VigyHost, sp| {
653 let key = format!("__converged::{}", lisp_string(&args[0], sp)?);
654 host.kv.insert(
655 key.clone(),
656 JsonValue::String(
657 time::OffsetDateTime::now_utc()
658 .format(&time::format_description::well_known::Rfc3339)
659 .unwrap_or_default(),
660 ),
661 );
662 host.kv_dirty.insert(key);
663 Ok(LispValue::Nil)
664 },
665 );
666
667 interp.register_fn(
669 "vigy-converged?",
670 Arity::Exact(1),
671 |args: &[LispValue], host: &mut VigyHost, sp| {
672 let key = format!("__converged::{}", lisp_string(&args[0], sp)?);
673 Ok(LispValue::Bool(host.kv.contains_key(&key)))
674 },
675 );
676}
677
678fn install_scheduling_intrinsics(interp: &mut Interpreter<VigyHost>) {
683 interp.register_fn(
685 "vigy-tick",
686 Arity::Exact(0),
687 |_args: &[LispValue], host: &mut VigyHost, _sp| Ok(LispValue::Int(host.tick_start_ms)),
688 );
689
690 interp.register_fn(
692 "vigy-tick-count",
693 Arity::Exact(0),
694 |_args: &[LispValue], host: &mut VigyHost, _sp| Ok(LispValue::Int(host.tick_count)),
695 );
696
697 interp.register_fn(
699 "vigy-since-last-tick",
700 Arity::Exact(0),
701 |_args: &[LispValue], host: &mut VigyHost, _sp| {
702 let v = host
703 .previous_tick_ms
704 .map(|p| host.tick_start_ms.saturating_sub(p))
705 .unwrap_or(-1);
706 Ok(LispValue::Int(v))
707 },
708 );
709
710 interp.register_fn(
716 "vigy-rate-limited?",
717 Arity::Exact(2),
718 |args: &[LispValue], host: &mut VigyHost, sp| {
719 let key = format!("__ratelimit::{}", lisp_string(&args[0], sp)?);
720 let min_ms = match &args[1] {
721 LispValue::Int(n) => *n,
722 other => {
723 return Err(EvalError::type_mismatch(
724 "integer min-interval-ms",
725 other.type_name(),
726 sp,
727 ))
728 }
729 };
730 let last = host.kv.get(&key).and_then(|v| v.as_i64()).unwrap_or(0);
731 let elapsed = host.tick_start_ms.saturating_sub(last);
732 if elapsed < min_ms {
733 Ok(LispValue::Bool(true))
734 } else {
735 host.kv
736 .insert(key.clone(), JsonValue::Number(host.tick_start_ms.into()));
737 host.kv_dirty.insert(key);
738 Ok(LispValue::Bool(false))
739 }
740 },
741 );
742
743 interp.register_fn(
746 "vigy-backoff-ms",
747 Arity::Exact(1),
748 |args: &[LispValue], _host: &mut VigyHost, sp| {
749 let attempt = match &args[0] {
750 LispValue::Int(n) => (*n).max(0) as u32,
751 other => {
752 return Err(EvalError::type_mismatch(
753 "integer attempt",
754 other.type_name(),
755 sp,
756 ))
757 }
758 };
759 let secs = 1u64
760 .checked_shl(attempt.min(5))
761 .unwrap_or(30);
762 let ms = (secs.min(30) * 1000) as i64;
763 Ok(LispValue::Int(ms))
764 },
765 );
766}
767
768fn install_diagnostic_intrinsics(interp: &mut Interpreter<VigyHost>) {
773 interp.register_fn(
775 "vigy-log",
776 Arity::Exact(2),
777 |args: &[LispValue], host: &mut VigyHost, sp| {
778 let level_str = lisp_string(&args[0], sp)?;
779 let level = LogLevel::parse(&level_str).ok_or_else(|| {
780 EvalError::native_fn(
781 "vigy-log",
782 format!("unknown level {level_str:?}; expected trace|debug|info|warn|error"),
783 sp,
784 )
785 })?;
786 let message = lisp_string(&args[1], sp)?;
787 host.log.push(LogEntry { level, message });
788 Ok(LispValue::Nil)
789 },
790 );
791
792 interp.register_fn(
794 "vigy-trace",
795 Arity::Exact(2),
796 |args: &[LispValue], host: &mut VigyHost, sp| {
797 let key = lisp_string(&args[0], sp)?;
798 host.trace.insert(key, lisp_to_json(&args[1]));
799 Ok(LispValue::Nil)
800 },
801 );
802
803 interp.register_fn(
805 "vigy-metric",
806 Arity::Exact(2),
807 |args: &[LispValue], host: &mut VigyHost, sp| {
808 let name = lisp_string(&args[0], sp)?;
809 let value = match &args[1] {
810 LispValue::Int(n) => *n as f64,
811 LispValue::Float(n) => *n,
812 other => {
813 return Err(EvalError::type_mismatch(
814 "numeric metric value",
815 other.type_name(),
816 sp,
817 ))
818 }
819 };
820 host.metrics.insert(name, value);
821 Ok(LispValue::Nil)
822 },
823 );
824
825 interp.register_fn(
827 "vigy-event",
828 Arity::Exact(2),
829 |args: &[LispValue], host: &mut VigyHost, sp| {
830 let kind = lisp_string(&args[0], sp)?;
831 let message = lisp_string(&args[1], sp)?;
832 host.events.push(HostEvent { kind, message });
833 Ok(LispValue::Nil)
834 },
835 );
836}
837
838fn register_sugar<F>(interp: &mut Interpreter<VigyHost>, name: &'static str, arity: Arity, f: F)
843where
844 F: Fn(&[LispValue], &mut VigyHost) -> std::result::Result<LispValue, EvalError>
845 + Send
846 + Sync
847 + 'static,
848{
849 interp.register_fn(name, arity, move |args: &[LispValue], host: &mut VigyHost, _sp| {
850 f(args, host)
851 });
852}
853
854fn parse_kind(
855 s: &str,
856 sp: tatara_lisp::Span,
857 fn_name: &'static str,
858) -> std::result::Result<ReconcileKind, EvalError> {
859 match s {
860 "noop" => Ok(ReconcileKind::Noop),
861 "defer" => Ok(ReconcileKind::Defer),
862 "pull" => Ok(ReconcileKind::Pull),
863 "push" => Ok(ReconcileKind::Push),
864 "create" => Ok(ReconcileKind::Create),
865 "update" => Ok(ReconcileKind::Update),
866 "delete" => Ok(ReconcileKind::Delete),
867 "apply" => Ok(ReconcileKind::Apply),
868 "restart" => Ok(ReconcileKind::Restart),
869 "custom" => Ok(ReconcileKind::Custom),
870 other => Err(EvalError::native_fn(
871 fn_name,
872 format!(
873 "unknown kind {other:?}; expected noop|defer|pull|push|create|update|delete|apply|restart|custom"
874 ),
875 sp,
876 )),
877 }
878}
879
880#[derive(Debug, Clone, Copy, Default)]
889pub struct ActionsExtension;
890impl HostExtension for ActionsExtension {
891 fn install(&self, interp: &mut Interpreter<VigyHost>) {
892 install_action_intrinsics(interp);
893 }
894}
895
896#[derive(Debug, Clone, Copy, Default)]
898pub struct StateExtension;
899impl HostExtension for StateExtension {
900 fn install(&self, interp: &mut Interpreter<VigyHost>) {
901 install_state_intrinsics(interp);
902 }
903}
904
905#[derive(Debug, Clone, Copy, Default)]
907pub struct KvExtension;
908impl HostExtension for KvExtension {
909 fn install(&self, interp: &mut Interpreter<VigyHost>) {
910 install_kv_intrinsics(interp);
911 }
912}
913
914#[derive(Debug, Clone, Copy, Default)]
916pub struct ConvergenceExtension;
917impl HostExtension for ConvergenceExtension {
918 fn install(&self, interp: &mut Interpreter<VigyHost>) {
919 install_convergence_intrinsics(interp);
920 }
921}
922
923#[derive(Debug, Clone, Copy, Default)]
926pub struct SchedulingExtension;
927impl HostExtension for SchedulingExtension {
928 fn install(&self, interp: &mut Interpreter<VigyHost>) {
929 install_scheduling_intrinsics(interp);
930 }
931}
932
933#[derive(Debug, Clone, Copy, Default)]
935pub struct DiagnosticsExtension;
936impl HostExtension for DiagnosticsExtension {
937 fn install(&self, interp: &mut Interpreter<VigyHost>) {
938 install_diagnostic_intrinsics(interp);
939 }
940}
941
942pub fn closure_extension<F>(f: F) -> ExtensionHandle
952where
953 F: Fn(&mut Interpreter<VigyHost>) + Send + Sync + 'static,
954{
955 struct ClosureExtension<F>(F);
956 impl<F: Fn(&mut Interpreter<VigyHost>) + Send + Sync + 'static> HostExtension
957 for ClosureExtension<F>
958 {
959 fn install(&self, interp: &mut Interpreter<VigyHost>) {
960 (self.0)(interp);
961 }
962 }
963 std::sync::Arc::new(ClosureExtension(f))
964}
965
966fn lisp_string(v: &LispValue, sp: tatara_lisp::Span) -> std::result::Result<String, EvalError> {
971 match v {
972 LispValue::Str(s) => Ok(s.to_string()),
973 LispValue::Symbol(s) => Ok(s.to_string()),
974 LispValue::Keyword(s) => Ok(s.to_string()),
975 other => Err(EvalError::type_mismatch(
976 "string|symbol|keyword",
977 other.type_name(),
978 sp,
979 )),
980 }
981}
982
983trait LispDisplay {
984 fn to_display_string(&self) -> String;
985}
986
987impl LispDisplay for LispValue {
988 fn to_display_string(&self) -> String {
989 match self {
990 LispValue::Str(s) => s.to_string(),
991 LispValue::Symbol(s) => s.to_string(),
992 LispValue::Keyword(s) => s.to_string(),
993 LispValue::Int(n) => n.to_string(),
994 LispValue::Float(n) => n.to_string(),
995 LispValue::Bool(b) => b.to_string(),
996 LispValue::Nil => "nil".to_string(),
997 other => format!("<{}>", other.type_name()),
998 }
999 }
1000}
1001
1002fn lisp_to_json(v: &LispValue) -> JsonValue {
1003 match v {
1004 LispValue::Nil => JsonValue::Null,
1005 LispValue::Bool(b) => JsonValue::Bool(*b),
1006 LispValue::Int(n) => JsonValue::Number((*n).into()),
1007 LispValue::Float(n) => serde_json::Number::from_f64(*n)
1008 .map(JsonValue::Number)
1009 .unwrap_or(JsonValue::Null),
1010 LispValue::Str(s) => JsonValue::String(s.to_string()),
1011 LispValue::Symbol(s) => JsonValue::String(s.to_string()),
1012 LispValue::Keyword(s) => JsonValue::String(format!(":{s}")),
1013 LispValue::List(items) => JsonValue::Array(items.iter().map(lisp_to_json).collect()),
1014 LispValue::Map(m) => {
1015 let mut obj = serde_json::Map::new();
1016 for (k, val) in m.iter() {
1017 let key_str = match k {
1018 tatara_lisp_eval::MapKey::Str(s) => s.to_string(),
1019 tatara_lisp_eval::MapKey::Keyword(s) => format!(":{s}"),
1020 tatara_lisp_eval::MapKey::Symbol(s) => s.to_string(),
1021 tatara_lisp_eval::MapKey::Int(i) => i.to_string(),
1022 tatara_lisp_eval::MapKey::Float(bits) => f64::from_bits(*bits).to_string(),
1023 tatara_lisp_eval::MapKey::Bool(b) => b.to_string(),
1024 tatara_lisp_eval::MapKey::Nil => "null".to_string(),
1025 };
1026 obj.insert(key_str, lisp_to_json(val));
1027 }
1028 JsonValue::Object(obj)
1029 }
1030 _ => JsonValue::String(format!("<{}>", v.type_name())),
1031 }
1032}
1033
1034fn json_to_lisp(v: &JsonValue) -> LispValue {
1040 use std::sync::Arc;
1041 match v {
1042 JsonValue::Null => LispValue::Nil,
1043 JsonValue::Bool(b) => LispValue::Bool(*b),
1044 JsonValue::Number(n) => {
1045 if let Some(i) = n.as_i64() {
1046 LispValue::Int(i)
1047 } else if let Some(f) = n.as_f64() {
1048 LispValue::Float(f)
1049 } else {
1050 LispValue::Nil
1051 }
1052 }
1053 JsonValue::String(s) => LispValue::Str(Arc::from(s.as_str())),
1054 JsonValue::Array(items) => {
1055 let converted: Vec<LispValue> = items.iter().map(json_to_lisp).collect();
1056 LispValue::List(Arc::new(converted))
1057 }
1058 JsonValue::Object(obj) => {
1059 let mut map = std::collections::HashMap::new();
1060 for (k, val) in obj {
1061 map.insert(
1062 tatara_lisp_eval::MapKey::Str(Arc::from(k.as_str())),
1063 json_to_lisp(val),
1064 );
1065 }
1066 LispValue::Map(Arc::new(map))
1067 }
1068 }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074
1075 #[test]
1076 fn empty_program() {
1077 let h = evaluate("", VigyHost::default()).unwrap();
1078 assert!(h.actions.is_empty());
1079 }
1080
1081 #[test]
1084 fn typed_action_verbs() {
1085 let h = evaluate(
1086 r#"
1087 (vigy-noop)
1088 (vigy-defer "waiting on upstream")
1089 (vigy-pull "session-x")
1090 (vigy-push "session-y")
1091 (vigy-create "session-z")
1092 (vigy-update "session-z")
1093 (vigy-delete-action "session-w")
1094 (vigy-apply "session-z")
1095 (vigy-restart "vigy-runtime")
1096 "#,
1097 VigyHost::default(),
1098 )
1099 .unwrap();
1100 let kinds: Vec<ReconcileKind> = h.actions.iter().map(|a| a.kind).collect();
1101 assert_eq!(
1102 kinds,
1103 vec![
1104 ReconcileKind::Noop,
1105 ReconcileKind::Defer,
1106 ReconcileKind::Pull,
1107 ReconcileKind::Push,
1108 ReconcileKind::Create,
1109 ReconcileKind::Update,
1110 ReconcileKind::Delete,
1111 ReconcileKind::Apply,
1112 ReconcileKind::Restart,
1113 ]
1114 );
1115 }
1116
1117 #[test]
1120 fn structured_state_buffers() {
1121 let h = evaluate(
1122 r#"
1123 (vigy-desired "replica_count" 3)
1124 (vigy-observed "replica_count" 2)
1125 (vigy-condition "Ready" "false" "BackoffActive" "1 replica unhealthy")
1126 (vigy-condition "InSync" "true")
1127 "#,
1128 VigyHost::default(),
1129 )
1130 .unwrap();
1131 assert_eq!(h.desired.get("replica_count").and_then(|v| v.as_i64()), Some(3));
1132 assert_eq!(h.observed.get("replica_count").and_then(|v| v.as_i64()), Some(2));
1133 assert_eq!(h.conditions.len(), 2);
1134 assert_eq!(h.conditions[0].name, "Ready");
1135 assert_eq!(h.conditions[0].status, ConditionStatus::False);
1136 assert_eq!(h.conditions[0].reason.as_deref(), Some("BackoffActive"));
1137 assert_eq!(h.conditions[1].name, "InSync");
1138 assert_eq!(h.conditions[1].status, ConditionStatus::True);
1139 }
1140
1141 #[test]
1144 fn kv_set_get_default() {
1145 let h = evaluate(
1146 r#"
1147 (vigy-set "attempts" 5)
1148 (vigy-set "label" "production")
1149 "#,
1150 VigyHost::default(),
1151 )
1152 .unwrap();
1153 assert_eq!(h.kv.get("attempts").and_then(|v| v.as_i64()), Some(5));
1154 assert_eq!(h.kv.get("label").and_then(|v| v.as_str()), Some("production"));
1155 assert!(h.kv_dirty.contains("attempts"));
1156 assert!(h.kv_dirty.contains("label"));
1157 }
1158
1159 #[test]
1160 fn kv_get_returns_previous_tick_value() {
1161 let mut host = VigyHost::default();
1163 host.kv.insert(
1164 "attempts".to_string(),
1165 serde_json::Value::Number(7.into()),
1166 );
1167 let h = evaluate(
1168 r#"
1169 (vigy-set "doubled" (* 2 (vigy-get "attempts")))
1170 "#,
1171 host,
1172 )
1173 .unwrap();
1174 assert_eq!(h.kv.get("doubled").and_then(|v| v.as_i64()), Some(14));
1175 }
1176
1177 #[test]
1178 fn kv_get_with_default() {
1179 let h = evaluate(
1180 r#"(vigy-set "x" (vigy-get "missing" 42))"#,
1181 VigyHost::default(),
1182 )
1183 .unwrap();
1184 assert_eq!(h.kv.get("x").and_then(|v| v.as_i64()), Some(42));
1185 }
1186
1187 #[test]
1188 fn kv_incr_starts_at_delta_when_absent() {
1189 let h = evaluate(r#"(vigy-incr "n")"#, VigyHost::default()).unwrap();
1190 assert_eq!(h.kv.get("n").and_then(|v| v.as_i64()), Some(1));
1191 let h2 = evaluate(r#"(vigy-incr "n" 4)"#, h).unwrap();
1192 assert_eq!(h2.kv.get("n").and_then(|v| v.as_i64()), Some(5));
1193 }
1194
1195 #[test]
1196 fn kv_has_and_del() {
1197 let h = evaluate(
1198 r#"
1199 (vigy-set "x" 1)
1200 (vigy-set "y" 2)
1201 (vigy-set "x-was-present" (vigy-has? "x"))
1202 (vigy-del "x")
1203 (vigy-set "x-present-after-del" (vigy-has? "x"))
1204 "#,
1205 VigyHost::default(),
1206 )
1207 .unwrap();
1208 assert_eq!(h.kv.get("x-was-present"), Some(&JsonValue::Bool(true)));
1209 assert_eq!(
1210 h.kv.get("x-present-after-del"),
1211 Some(&JsonValue::Bool(false))
1212 );
1213 assert!(h.kv_deleted.contains("x"));
1214 }
1215
1216 #[test]
1219 fn once_fires_only_once() {
1220 let h = evaluate(r#"(vigy-set "a" (vigy-once "init"))"#, VigyHost::default()).unwrap();
1221 assert_eq!(h.kv.get("a"), Some(&JsonValue::Bool(true)));
1222 let h2 = evaluate(r#"(vigy-set "a" (vigy-once "init"))"#, h).unwrap();
1224 assert_eq!(h2.kv.get("a"), Some(&JsonValue::Bool(false)));
1225 }
1226
1227 #[test]
1228 fn mark_and_check_converged() {
1229 let h = evaluate(
1230 r#"
1231 (vigy-set "before" (vigy-converged? "target"))
1232 (vigy-mark-converged "target")
1233 (vigy-set "after" (vigy-converged? "target"))
1234 "#,
1235 VigyHost::default(),
1236 )
1237 .unwrap();
1238 assert_eq!(h.kv.get("before"), Some(&JsonValue::Bool(false)));
1239 assert_eq!(h.kv.get("after"), Some(&JsonValue::Bool(true)));
1240 }
1241
1242 #[test]
1245 fn tick_count_and_since_last() {
1246 let mut host = VigyHost::default();
1247 host.tick_start_ms = 1000;
1248 host.previous_tick_ms = Some(800);
1249 host.tick_count = 7;
1250 let h = evaluate(
1251 r#"
1252 (vigy-set "tick" (vigy-tick))
1253 (vigy-set "count" (vigy-tick-count))
1254 (vigy-set "since" (vigy-since-last-tick))
1255 "#,
1256 host,
1257 )
1258 .unwrap();
1259 assert_eq!(h.kv.get("tick").and_then(|v| v.as_i64()), Some(1000));
1260 assert_eq!(h.kv.get("count").and_then(|v| v.as_i64()), Some(7));
1261 assert_eq!(h.kv.get("since").and_then(|v| v.as_i64()), Some(200));
1262 }
1263
1264 #[test]
1265 fn rate_limit_gates_within_window() {
1266 let mut host = VigyHost::default();
1267 host.tick_start_ms = 1000;
1268 let h = evaluate(
1270 r#"(vigy-set "first" (vigy-rate-limited? "k" 500))"#,
1271 host,
1272 )
1273 .unwrap();
1274 assert_eq!(h.kv.get("first"), Some(&JsonValue::Bool(false)));
1275
1276 let mut next = h;
1278 next.tick_start_ms = 1300;
1279 let h2 = evaluate(
1280 r#"(vigy-set "second" (vigy-rate-limited? "k" 500))"#,
1281 next,
1282 )
1283 .unwrap();
1284 assert_eq!(h2.kv.get("second"), Some(&JsonValue::Bool(true)));
1285
1286 let mut later = h2;
1288 later.tick_start_ms = 1600;
1289 let h3 = evaluate(
1290 r#"(vigy-set "third" (vigy-rate-limited? "k" 500))"#,
1291 later,
1292 )
1293 .unwrap();
1294 assert_eq!(h3.kv.get("third"), Some(&JsonValue::Bool(false)));
1295 }
1296
1297 #[test]
1298 fn backoff_curve_caps_at_30s() {
1299 let h = evaluate(
1300 r#"
1301 (vigy-set "b0" (vigy-backoff-ms 0))
1302 (vigy-set "b1" (vigy-backoff-ms 1))
1303 (vigy-set "b3" (vigy-backoff-ms 3))
1304 (vigy-set "b100" (vigy-backoff-ms 100))
1305 "#,
1306 VigyHost::default(),
1307 )
1308 .unwrap();
1309 assert_eq!(h.kv.get("b0").and_then(|v| v.as_i64()), Some(1000));
1310 assert_eq!(h.kv.get("b1").and_then(|v| v.as_i64()), Some(2000));
1311 assert_eq!(h.kv.get("b3").and_then(|v| v.as_i64()), Some(8000));
1312 assert_eq!(h.kv.get("b100").and_then(|v| v.as_i64()), Some(30000));
1313 }
1314
1315 #[tokio::test]
1320 async fn lisp_reconciler_runs_a_program() {
1321 let r = LispReconciler::standard("(vigy-noop)");
1322 let host = r.tick(VigyHost::default()).await.unwrap();
1323 assert_eq!(host.actions.len(), 1);
1324 assert_eq!(host.actions[0].kind, ReconcileKind::Noop);
1325 }
1326
1327 #[tokio::test]
1328 async fn noop_reconciler_returns_host_unchanged() {
1329 let r = NoopReconciler;
1330 let mut host = VigyHost::default();
1331 host.tick_start_ms = 42;
1332 let after = r.tick(host).await.unwrap();
1333 assert_eq!(after.tick_start_ms, 42);
1334 assert!(after.actions.is_empty());
1335 }
1336
1337 #[tokio::test]
1338 async fn chain_reconciler_threads_host_through_children() {
1339 let chain = ChainReconciler::new(vec![
1343 Box::new(LispReconciler::standard("(vigy-pull \"first\")")),
1344 Box::new(LispReconciler::standard("(vigy-push \"second\")")),
1345 Box::new(LispReconciler::standard("(vigy-apply \"third\")")),
1346 ]);
1347 let host = chain.tick(VigyHost::default()).await.unwrap();
1348 let kinds: Vec<_> = host.actions.iter().map(|a| a.kind).collect();
1349 assert_eq!(
1350 kinds,
1351 vec![
1352 ReconcileKind::Pull,
1353 ReconcileKind::Push,
1354 ReconcileKind::Apply,
1355 ]
1356 );
1357 }
1358
1359 #[tokio::test]
1360 async fn custom_host_extension_registers_intrinsic() {
1361 let custom = closure_extension(|interp| {
1365 interp.register_fn(
1366 "mado-tear-list-sessions",
1367 Arity::Exact(0),
1368 |_args: &[LispValue], host: &mut VigyHost, _sp| {
1369 host.actions
1370 .push(ReconcileAction::custom(serde_json::json!({"from": "mado"})));
1371 Ok(LispValue::Int(3))
1372 },
1373 );
1374 });
1375
1376 let mut extensions = standard_extensions();
1377 extensions.push(custom);
1378
1379 let r = LispReconciler::with_extensions(
1380 r#"
1381 (vigy-set "session_count" (mado-tear-list-sessions))
1382 "#,
1383 extensions,
1384 );
1385 let host = r.tick(VigyHost::default()).await.unwrap();
1386 assert_eq!(host.kv.get("session_count").and_then(|v| v.as_i64()), Some(3));
1387 assert_eq!(host.actions.len(), 1);
1388 assert_eq!(host.actions[0].kind, ReconcileKind::Custom);
1389 }
1390
1391 #[test]
1392 fn trace_metric_event() {
1393 let h = evaluate(
1394 r#"
1395 (vigy-trace "upstream_session_id" "abc-123")
1396 (vigy-metric "scrollback_bytes" 4096)
1397 (vigy-metric "lag_ratio" 0.42)
1398 (vigy-event "Reconciled" "all good")
1399 "#,
1400 VigyHost::default(),
1401 )
1402 .unwrap();
1403 assert_eq!(
1404 h.trace.get("upstream_session_id").and_then(|v| v.as_str()),
1405 Some("abc-123")
1406 );
1407 assert_eq!(h.metrics.get("scrollback_bytes"), Some(&4096.0));
1408 assert!((h.metrics.get("lag_ratio").unwrap() - 0.42).abs() < 1e-9);
1409 assert_eq!(h.events.len(), 1);
1410 assert_eq!(h.events[0].kind, "Reconciled");
1411 }
1412}