Skip to main content

vigy_eval/
lib.rs

1//! Tatara-lisp host bindings + framework intrinsics for vigy.
2//!
3//! ## The host
4//!
5//! [`VigyHost`] is the per-tick context handed to a vigy program.
6//! It carries five buffers the program populates via intrinsics:
7//!
8//! - **actions**     — ReconcileActions emitted (`vigy-emit` family)
9//! - **desired**     — keyed declarative "what should be true"
10//! - **observed**    — keyed recorded "what currently is true"
11//! - **conditions**  — kubernetes-style health/readiness signals
12//! - **log**         — structured log lines
13//! - **trace**       — typed kv traces for the run's audit record
14//! - **metrics**     — named numeric metrics for observability
15//! - **events**      — kubernetes-style events for tail consumers
16//!
17//! Plus two state surfaces the *runtime* manages (not the program):
18//!
19//! - **tick_start_ms** — readable via `(vigy-tick)`
20//! - **kv**            — persistent k/v store, hydrated at tick start,
21//!                       readable + writable via `(vigy-get/set/incr)`,
22//!                       saved back to the store at tick end
23//!
24//! ## Framework verb surface
25//!
26//! ### Actions (typed ReconcileKind)
27//! ```text
28//! (vigy-noop)                        — recorded; "nothing to do"
29//! (vigy-defer reason)                — "skip this tick; try next"
30//! (vigy-pull payload)                — fetch upstream state locally
31//! (vigy-push payload)                — push local state to upstream
32//! (vigy-create payload)              — target doesn't exist; create
33//! (vigy-update payload)              — target exists; modify
34//! (vigy-delete payload)              — target should not exist
35//! (vigy-apply payload)               — idempotent "make it match"
36//! (vigy-restart payload)             — runtime stuck; restart
37//! (vigy-emit kind payload?)          — escape hatch for unusual kinds
38//! ```
39//!
40//! ### Structured state
41//! ```text
42//! (vigy-desired k v)                 — declare what should be true
43//! (vigy-observed k v)                — record what currently is true
44//! (vigy-condition name status        — kubernetes-style condition.
45//!                  reason? message?)   status: "true"|"false"|"unknown"
46//! ```
47//!
48//! ### Persistent KV (survives across ticks; per-vigy scope)
49//! ```text
50//! (vigy-get k default?)              — read; default if absent
51//! (vigy-set k v)                     — write; saved on tick end
52//! (vigy-incr k delta)                — atomic numeric increment
53//! (vigy-has? k)                      — presence check
54//! (vigy-del k)                       — remove
55//! ```
56//!
57//! ### Convergence / idempotency
58//! ```text
59//! (vigy-once k)                      — true exactly once per vigy lifetime
60//! (vigy-mark-converged k)            — record permanent achievement
61//! (vigy-converged? k)                — read convergence flag
62//! ```
63//!
64//! ### Scheduling helpers
65//! ```text
66//! (vigy-tick)                        — epoch ms of this tick's start
67//! (vigy-tick-count)                  — N (Nth tick of this vigy)
68//! (vigy-since-last-tick)             — ms since previous tick
69//! (vigy-rate-limited? k min-ms)      — token-bucket gate; returns true
70//!                                       if k was acted on within min-ms
71//! (vigy-backoff-ms attempt)          — capped exponential (2^attempt * 1000ms, max 30s)
72//! ```
73//!
74//! ### Diagnostics
75//! ```text
76//! (vigy-log level msg)               — text log line
77//! (vigy-trace k v)                   — typed kv on the audit record
78//! (vigy-metric name f)               — numeric metric
79//! (vigy-event kind message)          — kubernetes-style event
80//! ```
81//!
82//! Plus the full tatara-lisp stdlib — arithmetic, comparison, list ops,
83//! strings, channels, fibers, higher-order helpers.
84//!
85//! ## Entry point
86//!
87//! [`evaluate`] takes a program + a fresh host (with `kv` pre-loaded by
88//! the runtime), evaluates, returns the host (now populated). The
89//! runtime drains buffers + persists dirty kv keys.
90
91use 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
100// Re-exports — hosts that ship their own HostExtension need access to
101// the lisp surface to register intrinsics. Exposing these here keeps
102// the dependency story clean ("add vigy-eval"; no tatara-lisp dep
103// needed for host code).
104pub use tatara_lisp::Span;
105pub use tatara_lisp_eval::{
106    Arity as ArityRe, EvalError as EvalErrorRe, Interpreter as InterpreterRe, Value as LispValueRe,
107};
108/// Re-exported `tatara_lisp_eval::Arity`. Host extensions specify the
109/// arity of their custom intrinsics with this.
110pub type ExtArity = Arity;
111/// Re-exported `tatara_lisp_eval::Value`. Host intrinsics return this
112/// from their callable.
113pub type ExtValue = LispValue;
114/// Re-exported `tatara_lisp_eval::Interpreter<VigyHost>` — the second
115/// argument to `HostExtension::install`.
116pub type ExtInterpreter = Interpreter<VigyHost>;
117/// Re-exported `tatara_lisp_eval::EvalError`. Host intrinsics return
118/// this for type mismatches / runtime errors.
119pub 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/// Per-tick host. Programs populate buffers via intrinsics; the runtime
132/// drains them after the tick + persists dirty kv keys.
133#[derive(Debug, Default)]
134pub struct VigyHost {
135    // ── tick metadata (read-only from program) ───────────────────
136    pub tick_start_ms: i64,
137    pub previous_tick_ms: Option<i64>,
138    pub tick_count: i64,
139
140    // ── action / decision buffers (program writes; runtime drains) ──
141    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    // ── structured state buffers ────────────────────────────────
148    pub desired: BTreeMap<String, JsonValue>,
149    pub observed: BTreeMap<String, JsonValue>,
150    pub conditions: Vec<Condition>,
151
152    // ── persistent kv (runtime loads on tick start; saves dirty on end) ──
153    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
192/// Evaluate a vigy program against a fresh host. Convenience wrapper
193/// over the trait-based path with the standard extension bundle —
194/// the entrypoint vigy-runtime uses by default. Embedders that want
195/// additional intrinsics call [`evaluate_with_extensions`] directly.
196pub fn evaluate(program: &str, host: VigyHost) -> Result<VigyHost> {
197    evaluate_with_extensions(program, host, &standard_extensions())
198}
199
200/// Evaluate a program with a custom extension bundle. Hosts (mado,
201/// tear-daemon) compose `standard_extensions()` with their own
202/// [`HostExtension`] impls — e.g. mado adds `MadoTearExtension` that
203/// registers `(mado-tear-list-sessions)` / `(mado-tear-attach)`
204/// intrinsics, the rest of the framework keeps working unchanged.
205pub 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
222/// Register every vigy framework intrinsic. Kept as a free function for
223/// the rare embedder that wants the entire bundle in one call without
224/// touching `Box<dyn HostExtension>`. Equivalent to installing the
225/// `standard_extensions()` list one at a time.
226pub 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
235// ─────────────────────────────────────────────────────────────────
236// Traits — the compounding surface
237// ─────────────────────────────────────────────────────────────────
238
239/// Pluggable intrinsic pack. The framework ships six standard
240/// extensions (`Actions`, `State`, `Kv`, `Convergence`, `Scheduling`,
241/// `Diagnostics`) — together they form `standard_extensions()`. Hosts
242/// add their own by implementing this trait and passing their impls
243/// to `evaluate_with_extensions` alongside the standard set.
244///
245/// Implementations should be cheap to construct + cheap to clone; the
246/// runtime currently instantiates them per-tick (room to cache later
247/// once a real benchmark calls for it).
248pub trait HostExtension: Send + Sync {
249    /// Register every intrinsic this extension owns on the interpreter.
250    /// Idempotent within a single evaluation — registering twice on
251    /// the same interpreter overrides the prior binding (tatara-lisp's
252    /// default behaviour). Across different interpreter instances each
253    /// install is independent.
254    fn install(&self, interp: &mut Interpreter<VigyHost>);
255}
256
257/// Shared handle for a HostExtension. `Arc` chosen over `Box` so the
258/// reconciler can hand its extension list to spawn_blocking without
259/// requiring every extension to be `Clone` (some impls might hold
260/// non-cloneable resources like a `tonic::Channel`).
261pub type ExtensionHandle = std::sync::Arc<dyn HostExtension>;
262
263/// The six standard extensions, in the order they install on a fresh
264/// interpreter. Order doesn't matter today (no two extensions share
265/// an intrinsic name) but is documented for stability.
266pub 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/// Typed reconciler — what runs each tick. tatara-lisp evaluation is
279/// the default implementation ([`LispReconciler`]) but the trait lets
280/// any execution model (Rust-native, Wasm, HTTP webhook) participate
281/// in the same runtime loop.
282///
283/// Reconcilers consume the input `VigyHost` (with kv hydrated by the
284/// runtime + tick metadata stamped) and return the host with buffers
285/// populated. The runtime drains buffers + persists dirty kv keys
286/// after the trait method returns.
287#[async_trait::async_trait]
288pub trait Reconciler: Send + Sync {
289    async fn tick(&self, host: VigyHost) -> Result<VigyHost>;
290}
291
292/// The default reconciler — evaluates a tatara-lisp program with the
293/// supplied extension bundle. Constructible two ways:
294///
295///   - `LispReconciler::standard(program)` uses `standard_extensions()`.
296///   - `LispReconciler::with_extensions(program, exts)` for hosts
297///     that want additional intrinsics.
298///
299/// Holds an owned program + a shared extension list. Cheap to clone
300/// (Arcs all the way down) so the runtime can rebuild it on every
301/// vigy edit without paying for interpreter construction up-front.
302pub 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        // Tatara-lisp eval is sync + CPU-bound. spawn_blocking keeps
330        // the tokio worker free; the reconciler returns its host
331        // verbatim once the lisp program finishes (or errors).
332        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
342/// A reconciler that does nothing. Tests + the "vigy is disabled"
343/// path use this to drive the runtime without any program. Returns
344/// the host unchanged.
345pub 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
354/// Composite reconciler — runs its children in order, threading the
355/// host through. Each child sees the buffer state the previous child
356/// wrote, so a chain can stack: "first reconciler observes from RPC,
357/// second writes a tatara-lisp policy over those observations."
358pub 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
378// ─────────────────────────────────────────────────────────────────
379// Actions
380// ─────────────────────────────────────────────────────────────────
381
382fn install_action_intrinsics(interp: &mut Interpreter<VigyHost>) {
383    // (vigy-emit kind payload?)
384    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    // sugar — one per kind. Use the constructors on ReconcileAction
412    // so anything wired into the action shape stays consistent.
413    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        // Named with -action suffix so it doesn't collide with the kv
442        // intrinsic `vigy-del` (which removes a kv key). Programs that
443        // emit a delete reconcile action use this verb explicitly.
444        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
460// ─────────────────────────────────────────────────────────────────
461// Structured state
462// ─────────────────────────────────────────────────────────────────
463
464fn install_state_intrinsics(interp: &mut Interpreter<VigyHost>) {
465    // (vigy-desired key value)
466    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    // (vigy-observed key value)
477    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    // (vigy-condition name status [reason [message]])
488    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
535// ─────────────────────────────────────────────────────────────────
536// Persistent KV
537// ─────────────────────────────────────────────────────────────────
538
539fn install_kv_intrinsics(interp: &mut Interpreter<VigyHost>) {
540    // (vigy-get key default?)
541    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    // (vigy-set key value)
555    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    // (vigy-incr key delta?) — defaults delta to 1.
569    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    // (vigy-has? key)
603    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    // (vigy-del key)
613    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
626// ─────────────────────────────────────────────────────────────────
627// Convergence / idempotency
628// ─────────────────────────────────────────────────────────────────
629
630fn install_convergence_intrinsics(interp: &mut Interpreter<VigyHost>) {
631    // (vigy-once key) — true exactly once across the vigy's lifetime.
632    // Internally: checks if "__once::<key>" is set in kv; sets it if not.
633    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    // (vigy-mark-converged key) — record permanent achievement.
649    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    // (vigy-converged? key)
668    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
678// ─────────────────────────────────────────────────────────────────
679// Scheduling helpers
680// ─────────────────────────────────────────────────────────────────
681
682fn install_scheduling_intrinsics(interp: &mut Interpreter<VigyHost>) {
683    // (vigy-tick) — current tick's epoch ms.
684    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    // (vigy-tick-count) — Nth tick for this vigy.
691    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    // (vigy-since-last-tick) — ms since previous tick. -1 if first tick.
698    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    // (vigy-rate-limited? key min-interval-ms) — returns true if `key`
711    // has been recorded as acted-on within the last min-interval-ms.
712    // Side-effect on false: records the current tick under key so the
713    // next call gates correctly. Pattern: gate the body of an action,
714    // not the observe step.
715    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    // (vigy-backoff-ms attempt) — capped exponential backoff.
744    //   attempt 0 → 1000ms, 1 → 2000, 2 → 4000, ..., capped at 30000.
745    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
768// ─────────────────────────────────────────────────────────────────
769// Diagnostics
770// ─────────────────────────────────────────────────────────────────
771
772fn install_diagnostic_intrinsics(interp: &mut Interpreter<VigyHost>) {
773    // (vigy-log level message)
774    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    // (vigy-trace key value)
793    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    // (vigy-metric name value) — value is float-coerced from int/float.
804    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    // (vigy-event kind message)
826    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
838// ─────────────────────────────────────────────────────────────────
839// Helpers
840// ─────────────────────────────────────────────────────────────────
841
842fn 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// ─────────────────────────────────────────────────────────────────
881// Standard HostExtension impls — each delegates to its free install
882// function. Trivial unit structs so consumers can compose them by
883// reference (`standard_extensions()`) or by name in their own bundles.
884// ─────────────────────────────────────────────────────────────────
885
886/// Typed action verbs: vigy-noop / defer / pull / push / create /
887/// update / delete-action / apply / restart / emit.
888#[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/// Structured state: vigy-desired / observed / condition.
897#[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/// Persistent KV: vigy-get / set / incr / has? / del.
906#[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/// Convergence: vigy-once / mark-converged / converged?.
915#[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/// Scheduling: vigy-tick / tick-count / since-last-tick /
924/// rate-limited? / backoff-ms.
925#[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/// Diagnostics: vigy-log / trace / metric / event.
934#[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
942/// Construct a HostExtension from a closure. Useful for one-off
943/// host-specific intrinsics that don't deserve their own named struct:
944///
945/// ```ignore
946/// let mado_ext = closure_extension(|interp| {
947///     interp.register_fn("mado-tear-list-sessions", Arity::Exact(0),
948///         |_args, host, _sp| { /* ... */ });
949/// });
950/// ```
951pub 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
966// ─────────────────────────────────────────────────────────────────
967// Helpers
968// ─────────────────────────────────────────────────────────────────
969
970fn 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
1034/// Conservative JSON → tatara-lisp conversion. Used by (vigy-get) to
1035/// surface kv values back into the program. Objects become Maps,
1036/// arrays become Lists, numbers preserve int-vs-float, strings stay
1037/// strings. Round-trips with lisp_to_json for the value shapes a
1038/// program writes via (vigy-set).
1039fn 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    // ── action verbs ─────────────────────────────────────────────
1082
1083    #[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    // ── structured state ────────────────────────────────────────
1118
1119    #[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    // ── persistent KV ───────────────────────────────────────────
1142
1143    #[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        // Simulate the runtime hydrating kv before evaluate.
1162        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    // ── convergence ─────────────────────────────────────────────
1217
1218    #[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        // Carry kv forward to simulate a second tick.
1223        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    // ── scheduling ─────────────────────────────────────────────
1243
1244    #[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        // First call: not limited (no prior record). Records timestamp.
1269        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        // Second call from a near-future tick should be limited.
1277        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        // Third call after the window: not limited.
1287        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    // ── diagnostics ────────────────────────────────────────────
1316
1317    // ── trait surface ──────────────────────────────────────────
1318
1319    #[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        // Three children, each appending a different action via
1340        // separate LispReconciler instances. Proves composition + that
1341        // the host buffers accumulate across the chain.
1342        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        // Closure-built extension that registers a custom intrinsic.
1362        // Then a tatara-lisp program that calls it — proves the trait
1363        // is a real extension point, not just type theater.
1364        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}