Skip to main content

mlua_swarm/enhance/
setting.rs

1//! `EnhanceSetting` — the internal model that configures an
2//! `EnhanceApplication`.
3//!
4//! The internal storage form is a **BlueprintId ref**: the store does not
5//! hold the Blueprint body itself; that is resolved through
6//! `BlueprintStore`. HTTP `POST`/`PUT` input goes through
7//! [`EnhanceSettingInput`] and receives Blueprint data inline; the server
8//! orchestrates a `BPStore.write_new` and converts to a Ref before
9//! persisting.
10//!
11//! Runtime parameters (`ttl_secs`, `meta`) live on `EnhanceSetting`. The
12//! `EnhanceApplication` fetches the setting on every tick and picks up
13//! changes, so setting edits act as a hot reload.
14
15use crate::application::VersionSelector;
16use crate::blueprint::store::BlueprintId;
17use crate::blueprint::{AgentDef, Blueprint};
18use serde::{Deserialize, Serialize};
19
20/// Internal storage form — the view held by the store and by
21/// `EnhanceApplication`. A `BlueprintId` ref plus runtime parameters.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EnhanceSetting {
24    /// Setting id — the server's single default setting uses `"default"`.
25    pub id: String,
26    /// The Blueprint this setting resolves to, via `BlueprintStore`.
27    pub blueprint_id: BlueprintId,
28    /// Operator-session lifetime (the TTL passed to `Engine::attach`).
29    pub ttl_secs: u64,
30    /// Which `BlueprintVersion` to take (`Latest` / `Fixed` /
31    /// `SemverReq`).
32    #[serde(default)]
33    pub version: VersionSelector,
34    /// Enhance-flow verifier axes: on/off. Injected into the init ctx as
35    /// `$.verifiers` and fanned out in parallel by the flow.ir `Fanout`.
36    /// An empty array skips verification — the committer commits
37    /// unconditionally. Default: the four axes `["des", "canonical",
38    /// "noop", "agent-ref"]`.
39    #[serde(default = "default_verifier_axes")]
40    pub verifier_axes: Vec<String>,
41    /// Overrides the Blueprint's own `patch-spawner` agent definition.
42    ///
43    /// `None` = use whatever the orbit Blueprint declares. `Some(def)`
44    /// swaps that agent out at dispatch time, so the spawner's execution
45    /// backend (`agent_block` / `subprocess` / `operator`) can be changed
46    /// without rewriting the Blueprint. Dispatch fails loud when the
47    /// orbit Blueprint declares no agent under that name — a silently
48    /// ignored override is the worst way for this to surface.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub spawner: Option<AgentDef>,
51    /// Extension metadata slot (currently empty).
52    #[serde(default)]
53    pub meta: EnhanceSettingMeta,
54}
55
56fn default_verifier_axes() -> Vec<String> {
57    vec![
58        "des".to_string(),
59        "canonical".to_string(),
60        "noop".to_string(),
61        "agent-ref".to_string(),
62    ]
63}
64
65/// HTTP `POST`/`PUT` input shape — the caller's view. Blueprint data is
66/// inline; the server does `BPStore.write_new` and converts it to a Ref
67/// before persisting.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct EnhanceSettingInput {
70    /// Setting id — the server's single default setting uses `"default"`.
71    pub id: String,
72    /// Blueprint data inline; the server persists it via `BPStore.write_new`
73    /// and converts it to a `blueprint_id` ref before storing.
74    pub blueprint: Blueprint,
75    /// Operator-session lifetime (the TTL passed to `Engine::attach`).
76    pub ttl_secs: u64,
77    /// Which `BlueprintVersion` to take (`Latest` / `Fixed` / `SemverReq`).
78    #[serde(default)]
79    pub version: VersionSelector,
80    /// Enhance-flow verifier axes: on/off. Defaults to the four canonical
81    /// axes when omitted.
82    #[serde(default = "default_verifier_axes")]
83    pub verifier_axes: Vec<String>,
84    /// Overrides the Blueprint's own `patch-spawner` agent definition —
85    /// carried through to [`EnhanceSetting::spawner`] verbatim by
86    /// [`EnhanceSettingInput::into_ref`]. It is *not* folded into the
87    /// Blueprint that gets persisted: the override is a setting-level
88    /// knob, so editing the setting reswaps the spawner without writing
89    /// a new Blueprint version.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub spawner: Option<AgentDef>,
92    /// Extension metadata slot (currently empty).
93    #[serde(default)]
94    pub meta: EnhanceSettingMeta,
95}
96
97impl EnhanceSettingInput {
98    /// Convert an inline-data input into the Ref form
99    /// (`EnhanceSetting`). The Blueprint's `id` becomes the
100    /// setting's `blueprint_id`.
101    pub fn into_ref(self) -> (Blueprint, EnhanceSetting) {
102        let blueprint_id = self.blueprint.id.clone();
103        (
104            self.blueprint,
105            EnhanceSetting {
106                id: self.id,
107                blueprint_id,
108                ttl_secs: self.ttl_secs,
109                version: self.version,
110                verifier_axes: self.verifier_axes,
111                spawner: self.spawner,
112                meta: self.meta,
113            },
114        )
115    }
116}
117
118/// Extension metadata attached to an `EnhanceSetting`. Placeholder —
119/// something will land here for certain, so the slot exists up front.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct EnhanceSettingMeta {}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::enhance::blueprint::default_blueprint;
127
128    #[test]
129    fn default_verifier_axes_has_4_canonical_axes() {
130        let axes = default_verifier_axes();
131        assert_eq!(axes, vec!["des", "canonical", "noop", "agent-ref"]);
132    }
133
134    #[test]
135    fn input_into_ref_splits_blueprint_and_setting() {
136        let bp = default_blueprint();
137        let bp_id = bp.id.clone();
138        let input = EnhanceSettingInput {
139            id: "s1".into(),
140            blueprint: bp,
141            ttl_secs: 60,
142            version: VersionSelector::default(),
143            verifier_axes: default_verifier_axes(),
144            spawner: None,
145            meta: EnhanceSettingMeta::default(),
146        };
147        let (split_bp, setting) = input.into_ref();
148        assert_eq!(setting.id, "s1");
149        assert_eq!(setting.blueprint_id, bp_id);
150        assert_eq!(setting.ttl_secs, 60);
151        assert_eq!(setting.verifier_axes.len(), 4);
152        assert_eq!(split_bp.id, bp_id);
153    }
154
155    #[test]
156    fn setting_serde_roundtrip_preserves_verifier_axes() {
157        let bp_id = BlueprintId::new("bp-xyz".to_string());
158        let s = EnhanceSetting {
159            id: "s2".into(),
160            blueprint_id: bp_id,
161            ttl_secs: 30,
162            version: VersionSelector::default(),
163            verifier_axes: vec!["des".into(), "noop".into()],
164            spawner: None,
165            meta: EnhanceSettingMeta::default(),
166        };
167        let j = serde_json::to_value(&s).unwrap();
168        let s2: EnhanceSetting = serde_json::from_value(j).unwrap();
169        assert_eq!(s2.verifier_axes, vec!["des", "noop"]);
170        assert_eq!(s2.ttl_secs, 30);
171    }
172
173    #[test]
174    fn setting_deserialize_applies_default_verifier_axes_when_omitted() {
175        let json = serde_json::json!({
176            "id": "s3",
177            "blueprint_id": "bp-1",
178            "ttl_secs": 10,
179        });
180        let s: EnhanceSetting = serde_json::from_value(json).unwrap();
181        assert_eq!(s.verifier_axes, default_verifier_axes());
182    }
183
184    #[test]
185    fn setting_deserialize_without_spawner_is_none_and_omits_it_on_serialize() {
186        // Every pre-existing stored setting predates `spawner`, so the
187        // absent key must round-trip as `None` and stay absent.
188        let json = serde_json::json!({
189            "id": "s4",
190            "blueprint_id": "bp-1",
191            "ttl_secs": 10,
192        });
193        let s: EnhanceSetting = serde_json::from_value(json).unwrap();
194        assert!(s.spawner.is_none());
195        let back = serde_json::to_value(&s).unwrap();
196        assert!(back.get("spawner").is_none());
197    }
198
199    #[test]
200    fn input_into_ref_carries_spawner_override_to_the_setting() {
201        let bp = default_blueprint();
202        let spawner: AgentDef = serde_json::from_value(serde_json::json!({
203            "name": "patch-spawner",
204            "kind": "subprocess",
205            "spec": { "program": "true", "args": [] },
206        }))
207        .unwrap();
208        let input = EnhanceSettingInput {
209            id: "s5".into(),
210            blueprint: bp,
211            ttl_secs: 60,
212            version: VersionSelector::default(),
213            verifier_axes: default_verifier_axes(),
214            spawner: Some(spawner.clone()),
215            meta: EnhanceSettingMeta::default(),
216        };
217        let (split_bp, setting) = input.into_ref();
218        assert_eq!(setting.spawner.as_ref(), Some(&spawner));
219        // The override is a setting-level knob — it must not be folded
220        // into the Blueprint that gets persisted.
221        let bp_spawner = split_bp
222            .agents
223            .iter()
224            .find(|a| a.name == "patch-spawner")
225            .expect("default blueprint declares a patch-spawner agent");
226        assert_ne!(bp_spawner, &spawner);
227    }
228}