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 /// Wall-clock ceiling in seconds on one enhance epoch — one issue
29 /// popped, dispatched, and driven to a commit decision.
30 ///
31 /// Also passed to `Engine::attach` as the operator-session TTL, where
32 /// it has no effect: `82d9da9` exempted `Role::Operator` tokens from
33 /// the expiry check. The ceiling
34 /// [`crate::application::enhance::EnhanceApplication`] wraps around the
35 /// launch is what this number actually does — see that type's
36 /// `dispatch_one` for what a fired ceiling leaves behind, and
37 /// `mse://guides/enhance-flow` ("The epoch ceiling") for the
38 /// author-facing account.
39 ///
40 /// `0` is refused at dispatch rather than read as "unbounded".
41 pub ttl_secs: u64,
42 /// Which `BlueprintVersion` to take (`Latest` / `Fixed` /
43 /// `SemverReq`).
44 #[serde(default)]
45 pub version: VersionSelector,
46 /// Enhance-flow verifier axes: on/off. Injected into the init ctx as
47 /// `$.verifiers` and fanned out in parallel by the flow.ir `Fanout`.
48 /// An empty array skips verification — the committer commits
49 /// unconditionally. Default: the four axes `["des", "canonical",
50 /// "noop", "agent-ref"]`.
51 #[serde(default = "default_verifier_axes")]
52 pub verifier_axes: Vec<String>,
53 /// Overrides the Blueprint's own `patch-spawner` agent definition.
54 ///
55 /// `None` = use whatever the orbit Blueprint declares. `Some(def)`
56 /// swaps that agent out at dispatch time, so the spawner's execution
57 /// backend (`agent_block` / `subprocess` / `operator`) can be changed
58 /// without rewriting the Blueprint. Dispatch fails loud when the
59 /// orbit Blueprint declares no agent under that name — a silently
60 /// ignored override is the worst way for this to surface.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub spawner: Option<AgentDef>,
63 /// Extension metadata slot (currently empty).
64 #[serde(default)]
65 pub meta: EnhanceSettingMeta,
66}
67
68fn default_verifier_axes() -> Vec<String> {
69 vec![
70 "des".to_string(),
71 "canonical".to_string(),
72 "noop".to_string(),
73 "agent-ref".to_string(),
74 ]
75}
76
77/// HTTP `POST`/`PUT` input shape — the caller's view. Blueprint data is
78/// inline; the server does `BPStore.write_new` and converts it to a Ref
79/// before persisting.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EnhanceSettingInput {
82 /// Setting id — the server's single default setting uses `"default"`.
83 pub id: String,
84 /// Blueprint data inline; the server persists it via `BPStore.write_new`
85 /// and converts it to a `blueprint_id` ref before storing.
86 pub blueprint: Blueprint,
87 /// Wall-clock ceiling in seconds on one enhance epoch. Must be greater
88 /// than 0 — see [`EnhanceSetting::ttl_secs`], the field this is stored
89 /// as.
90 pub ttl_secs: u64,
91 /// Which `BlueprintVersion` to take (`Latest` / `Fixed` / `SemverReq`).
92 #[serde(default)]
93 pub version: VersionSelector,
94 /// Enhance-flow verifier axes: on/off. Defaults to the four canonical
95 /// axes when omitted.
96 #[serde(default = "default_verifier_axes")]
97 pub verifier_axes: Vec<String>,
98 /// Overrides the Blueprint's own `patch-spawner` agent definition —
99 /// carried through to [`EnhanceSetting::spawner`] verbatim by
100 /// [`EnhanceSettingInput::into_ref`]. It is *not* folded into the
101 /// Blueprint that gets persisted: the override is a setting-level
102 /// knob, so editing the setting reswaps the spawner without writing
103 /// a new Blueprint version.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub spawner: Option<AgentDef>,
106 /// Extension metadata slot (currently empty).
107 #[serde(default)]
108 pub meta: EnhanceSettingMeta,
109}
110
111impl EnhanceSettingInput {
112 /// Convert an inline-data input into the Ref form
113 /// (`EnhanceSetting`). The Blueprint's `id` becomes the
114 /// setting's `blueprint_id`.
115 pub fn into_ref(self) -> (Blueprint, EnhanceSetting) {
116 let blueprint_id = self.blueprint.id.clone();
117 (
118 self.blueprint,
119 EnhanceSetting {
120 id: self.id,
121 blueprint_id,
122 ttl_secs: self.ttl_secs,
123 version: self.version,
124 verifier_axes: self.verifier_axes,
125 spawner: self.spawner,
126 meta: self.meta,
127 },
128 )
129 }
130}
131
132/// Extension metadata attached to an `EnhanceSetting`. Placeholder —
133/// something will land here for certain, so the slot exists up front.
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135pub struct EnhanceSettingMeta {}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::enhance::blueprint::default_blueprint;
141
142 #[test]
143 fn default_verifier_axes_has_4_canonical_axes() {
144 let axes = default_verifier_axes();
145 assert_eq!(axes, vec!["des", "canonical", "noop", "agent-ref"]);
146 }
147
148 #[test]
149 fn input_into_ref_splits_blueprint_and_setting() {
150 let bp = default_blueprint();
151 let bp_id = bp.id.clone();
152 let input = EnhanceSettingInput {
153 id: "s1".into(),
154 blueprint: bp,
155 ttl_secs: 60,
156 version: VersionSelector::default(),
157 verifier_axes: default_verifier_axes(),
158 spawner: None,
159 meta: EnhanceSettingMeta::default(),
160 };
161 let (split_bp, setting) = input.into_ref();
162 assert_eq!(setting.id, "s1");
163 assert_eq!(setting.blueprint_id, bp_id);
164 assert_eq!(setting.ttl_secs, 60);
165 assert_eq!(setting.verifier_axes.len(), 4);
166 assert_eq!(split_bp.id, bp_id);
167 }
168
169 #[test]
170 fn setting_serde_roundtrip_preserves_verifier_axes() {
171 let bp_id = BlueprintId::new("bp-xyz".to_string());
172 let s = EnhanceSetting {
173 id: "s2".into(),
174 blueprint_id: bp_id,
175 ttl_secs: 30,
176 version: VersionSelector::default(),
177 verifier_axes: vec!["des".into(), "noop".into()],
178 spawner: None,
179 meta: EnhanceSettingMeta::default(),
180 };
181 let j = serde_json::to_value(&s).unwrap();
182 let s2: EnhanceSetting = serde_json::from_value(j).unwrap();
183 assert_eq!(s2.verifier_axes, vec!["des", "noop"]);
184 assert_eq!(s2.ttl_secs, 30);
185 }
186
187 #[test]
188 fn setting_deserialize_applies_default_verifier_axes_when_omitted() {
189 let json = serde_json::json!({
190 "id": "s3",
191 "blueprint_id": "bp-1",
192 "ttl_secs": 10,
193 });
194 let s: EnhanceSetting = serde_json::from_value(json).unwrap();
195 assert_eq!(s.verifier_axes, default_verifier_axes());
196 }
197
198 #[test]
199 fn setting_deserialize_without_spawner_is_none_and_omits_it_on_serialize() {
200 // Every pre-existing stored setting predates `spawner`, so the
201 // absent key must round-trip as `None` and stay absent.
202 let json = serde_json::json!({
203 "id": "s4",
204 "blueprint_id": "bp-1",
205 "ttl_secs": 10,
206 });
207 let s: EnhanceSetting = serde_json::from_value(json).unwrap();
208 assert!(s.spawner.is_none());
209 let back = serde_json::to_value(&s).unwrap();
210 assert!(back.get("spawner").is_none());
211 }
212
213 #[test]
214 fn input_into_ref_carries_spawner_override_to_the_setting() {
215 let bp = default_blueprint();
216 let spawner: AgentDef = serde_json::from_value(serde_json::json!({
217 "name": "patch-spawner",
218 "kind": "subprocess",
219 "spec": { "program": "true", "args": [] },
220 }))
221 .unwrap();
222 let input = EnhanceSettingInput {
223 id: "s5".into(),
224 blueprint: bp,
225 ttl_secs: 60,
226 version: VersionSelector::default(),
227 verifier_axes: default_verifier_axes(),
228 spawner: Some(spawner.clone()),
229 meta: EnhanceSettingMeta::default(),
230 };
231 let (split_bp, setting) = input.into_ref();
232 assert_eq!(setting.spawner.as_ref(), Some(&spawner));
233 // The override is a setting-level knob — it must not be folded
234 // into the Blueprint that gets persisted.
235 let bp_spawner = split_bp
236 .agents
237 .iter()
238 .find(|a| a.name == "patch-spawner")
239 .expect("default blueprint declares a patch-spawner agent");
240 assert_ne!(bp_spawner, &spawner);
241 }
242}