Skip to main content

agent_orchestrator/crd/
store.rs

1pub use orchestrator_config::resource_store::*;
2
3use crate::crd::projection::CrdProjectable;
4use std::collections::HashMap;
5
6/// Extension trait adding CRD projection methods to ResourceStore.
7/// These methods require the CrdProjectable trait which stays in core
8/// because its implementations depend on resource converters.
9pub trait ResourceStoreExt {
10    /// Project all CRs of a given kind into a typed HashMap.
11    fn project_map<T: CrdProjectable>(&self) -> HashMap<String, T>;
12    /// Project a singleton CR of a given kind.
13    fn project_singleton<T: CrdProjectable>(&self) -> Option<T>;
14    /// Project a singleton CR of a given kind within a specific project scope.
15    fn project_singleton_for_project<T: CrdProjectable>(&self, project: &str) -> Option<T>;
16}
17
18impl ResourceStoreExt for ResourceStore {
19    fn project_map<T: CrdProjectable>(&self) -> HashMap<String, T> {
20        let kind = T::crd_kind();
21        let mut result = HashMap::new();
22        for cr in self.list_by_kind(kind) {
23            if let Ok(typed) = T::from_cr_spec(&cr.spec) {
24                result.insert(cr.metadata.name.clone(), typed);
25            }
26        }
27        result
28    }
29
30    fn project_singleton<T: CrdProjectable>(&self) -> Option<T> {
31        let kind = T::crd_kind();
32        let items = self.list_by_kind(kind);
33        items
34            .into_iter()
35            .next()
36            .and_then(|cr| T::from_cr_spec(&cr.spec).ok())
37    }
38
39    fn project_singleton_for_project<T: CrdProjectable>(&self, project: &str) -> Option<T> {
40        let kind = T::crd_kind();
41        let items = self.list_by_kind_for_project(kind, project);
42        items
43            .into_iter()
44            .next()
45            .and_then(|cr| T::from_cr_spec(&cr.spec).ok())
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::cli_types::ResourceMetadata;
53    use crate::config::{AgentConfig, StepTemplateConfig};
54    use crate::crd::projection::CrdProjectable;
55    use crate::crd::types::CustomResource;
56
57    fn make_cr(kind: &str, name: &str, spec: serde_json::Value) -> CustomResource {
58        CustomResource {
59            kind: kind.to_string(),
60            api_version: "orchestrator.dev/v2".to_string(),
61            metadata: ResourceMetadata {
62                name: name.to_string(),
63                project: None,
64                labels: None,
65                annotations: None,
66            },
67            spec,
68            generation: 1,
69            created_at: "2026-01-01T00:00:00Z".to_string(),
70            updated_at: "2026-01-01T00:00:00Z".to_string(),
71        }
72    }
73
74    #[test]
75    fn put_and_get() {
76        let mut store = ResourceStore::default();
77        let cr = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
78        assert_eq!(store.put(cr.clone()), ApplyResult::Created);
79        assert!(store.get("Foo", "bar").is_some());
80        assert!(store.get("Foo", "missing").is_none());
81    }
82
83    #[test]
84    fn put_unchanged() {
85        let mut store = ResourceStore::default();
86        let cr = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
87        store.put(cr.clone());
88        assert_eq!(store.put(cr), ApplyResult::Unchanged);
89    }
90
91    #[test]
92    fn put_configured() {
93        let mut store = ResourceStore::default();
94        let cr1 = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
95        store.put(cr1);
96        let cr2 = make_cr("Foo", "bar", serde_json::json!({"x": 2}));
97        assert_eq!(store.put(cr2), ApplyResult::Configured);
98    }
99
100    #[test]
101    fn remove_existing() {
102        let mut store = ResourceStore::default();
103        let cr = make_cr("Foo", "bar", serde_json::json!({}));
104        store.put(cr);
105        assert!(store.remove("Foo", "bar").is_some());
106        assert!(store.get("Foo", "bar").is_none());
107    }
108
109    #[test]
110    fn remove_missing() {
111        let mut store = ResourceStore::default();
112        assert!(store.remove("Foo", "bar").is_none());
113    }
114
115    #[test]
116    fn list_by_kind() {
117        let mut store = ResourceStore::default();
118        store.put(make_cr("Foo", "a", serde_json::json!({})));
119        store.put(make_cr("Foo", "b", serde_json::json!({})));
120        store.put(make_cr("Bar", "c", serde_json::json!({})));
121        assert_eq!(store.list_by_kind("Foo").len(), 2);
122        assert_eq!(store.list_by_kind("Bar").len(), 1);
123        assert_eq!(store.list_by_kind("Baz").len(), 0);
124    }
125
126    #[test]
127    fn generation_increments() {
128        let mut store = ResourceStore::default();
129        assert_eq!(store.generation(), 0);
130        store.put(make_cr("Foo", "a", serde_json::json!({})));
131        assert_eq!(store.generation(), 1);
132        store.put(make_cr("Foo", "b", serde_json::json!({})));
133        assert_eq!(store.generation(), 2);
134        store.remove("Foo", "a");
135        assert_eq!(store.generation(), 3);
136    }
137
138    #[test]
139    fn is_empty_and_len() {
140        let mut store = ResourceStore::default();
141        assert!(store.is_empty());
142        assert_eq!(store.len(), 0);
143        store.put(make_cr("Foo", "a", serde_json::json!({})));
144        assert!(!store.is_empty());
145        assert_eq!(store.len(), 1);
146    }
147
148    #[test]
149    fn project_map_for_agents() {
150        let mut store = ResourceStore::default();
151        let agent = AgentConfig {
152            enabled: true,
153            command: "echo {prompt}".to_string(),
154            capabilities: vec!["plan".to_string()],
155            ..Default::default()
156        };
157        let spec_val = agent.to_cr_spec();
158        store.put(make_cr("Agent", "test-agent", spec_val));
159
160        let map: HashMap<String, AgentConfig> = store.project_map();
161        assert_eq!(map.len(), 1);
162        let loaded = map.get("test-agent").expect("should exist");
163        assert_eq!(loaded.command, "echo {prompt}");
164    }
165
166    #[test]
167    fn project_singleton() {
168        let mut store = ResourceStore::default();
169        let tmpl = StepTemplateConfig {
170            prompt: "do qa".to_string(),
171            description: None,
172        };
173        let spec_val = tmpl.to_cr_spec();
174        store.put(make_cr("StepTemplate", "qa", spec_val));
175
176        let loaded: Option<StepTemplateConfig> = store.project_singleton();
177        let loaded = loaded.expect("should project singleton");
178        assert_eq!(loaded.prompt, "do qa");
179    }
180
181    #[test]
182    fn cross_kind_key_isolation() {
183        let mut store = ResourceStore::default();
184        // Use Trigger (cluster-scoped) and Project (cluster-scoped) to test
185        // cross-kind key isolation without project-scoping complications.
186        store.put(make_cr("Trigger", "alpha", serde_json::json!({"a": 1})));
187        store.put(make_cr("Project", "alpha", serde_json::json!({"w": 2})));
188        assert_eq!(store.len(), 2);
189        assert_eq!(store.get("Trigger", "alpha").unwrap().spec["a"], 1);
190        assert_eq!(store.get("Project", "alpha").unwrap().spec["w"], 2);
191        store.remove("Trigger", "alpha");
192        assert!(store.get("Trigger", "alpha").is_none());
193        assert!(store.get("Project", "alpha").is_some());
194    }
195
196    #[test]
197    fn list_by_kind_does_not_match_prefix_substring() {
198        let mut store = ResourceStore::default();
199        store.put(make_cr("Foo", "x", serde_json::json!({})));
200        store.put(make_cr("FooBar", "y", serde_json::json!({})));
201        assert_eq!(store.list_by_kind("Foo").len(), 1);
202        assert_eq!(store.list_by_kind("FooBar").len(), 1);
203    }
204
205    #[test]
206    fn generation_does_not_increment_on_failed_remove() {
207        let mut store = ResourceStore::default();
208        store.put(make_cr("X", "a", serde_json::json!({})));
209        let gen_before = store.generation();
210        store.remove("X", "nonexistent");
211        assert_eq!(store.generation(), gen_before);
212    }
213
214    #[test]
215    fn generation_increments_on_unchanged_put() {
216        let mut store = ResourceStore::default();
217        let cr = make_cr("X", "a", serde_json::json!({}));
218        store.put(cr.clone());
219        let gen_after_create = store.generation();
220        store.put(cr);
221        assert_eq!(store.generation(), gen_after_create + 1);
222    }
223
224    #[test]
225    fn get_namespaced_uses_three_segment_key() {
226        let mut store = ResourceStore::default();
227        let cr = CustomResource {
228            kind: "Agent".to_string(),
229            api_version: "orchestrator.dev/v2".to_string(),
230            metadata: ResourceMetadata {
231                name: "my-agent".to_string(),
232                project: Some("proj1".to_string()),
233                labels: None,
234                annotations: None,
235            },
236            spec: serde_json::json!({}),
237            generation: 1,
238            created_at: "t".to_string(),
239            updated_at: "t".to_string(),
240        };
241        store
242            .resources_mut()
243            .insert("Agent/proj1/my-agent".to_string(), cr);
244        assert!(store.get_namespaced("Agent", "proj1", "my-agent").is_some());
245        assert!(store.get_namespaced("Agent", "proj2", "my-agent").is_none());
246        assert!(store.get("Agent", "my-agent").is_none());
247    }
248
249    #[test]
250    fn project_singleton_runtime_policy() {
251        use crate::config::{ResumeConfig, RunnerConfig};
252        use crate::crd::projection::RuntimePolicyProjection;
253
254        let mut store = ResourceStore::default();
255        let rp = RuntimePolicyProjection {
256            runner: RunnerConfig::default(),
257            resume: ResumeConfig { auto: true },
258            observability: crate::config::ObservabilityConfig::default(),
259        };
260        store.put(make_cr("RuntimePolicy", "default", rp.to_cr_spec()));
261        let projected: Option<RuntimePolicyProjection> = store.project_singleton();
262        let p = projected.expect("should project RuntimePolicy singleton");
263        assert!(p.resume.auto);
264        assert_eq!(p.runner.shell, "/bin/bash");
265    }
266
267    #[test]
268    fn project_map_skips_corrupted_specs() {
269        let mut store = ResourceStore::default();
270        let good = AgentConfig {
271            enabled: true,
272            command: "echo ok".to_string(),
273            ..Default::default()
274        };
275        store.put(make_cr("Agent", "good", good.to_cr_spec()));
276        store.put(make_cr(
277            "Agent",
278            "bad",
279            serde_json::json!({"not_command": 42}),
280        ));
281        let map: HashMap<String, AgentConfig> = store.project_map();
282        assert_eq!(map.len(), 1);
283        assert!(map.contains_key("good"));
284        assert!(!map.contains_key("bad"));
285    }
286
287    #[test]
288    fn project_singleton_returns_none_for_empty_store() {
289        let store = ResourceStore::default();
290        let runtime: Option<crate::crd::projection::RuntimePolicyProjection> =
291            store.project_singleton();
292        assert!(runtime.is_none());
293    }
294
295    #[test]
296    fn put_auto_assigns_default_project_for_project_scoped_kinds() {
297        let mut store = ResourceStore::default();
298        let cr = make_cr(
299            "Agent",
300            "my-agent",
301            serde_json::json!({"command": "echo test"}),
302        );
303        assert!(cr.metadata.project.is_none());
304        store.put(cr);
305        assert!(
306            store
307                .get_namespaced("Agent", crate::config::DEFAULT_PROJECT_ID, "my-agent")
308                .is_some()
309        );
310        assert!(store.get("Agent", "my-agent").is_none());
311    }
312
313    #[test]
314    fn put_keeps_system_project_for_cluster_scoped_kinds() {
315        let mut store = ResourceStore::default();
316        // Project is cluster-scoped — stays in _system when no project specified.
317        let cr = make_cr("Project", "my-project", serde_json::json!({}));
318        store.put(cr);
319        assert!(store.get("Project", "my-project").is_some());
320    }
321
322    #[test]
323    fn put_assigns_default_project_for_runtime_policy() {
324        let mut store = ResourceStore::default();
325        // RuntimePolicy is project-scoped — auto-assigned to DEFAULT_PROJECT_ID.
326        let cr = make_cr("RuntimePolicy", "runtime", serde_json::json!({}));
327        store.put(cr);
328        assert!(
329            store
330                .get_namespaced(
331                    "RuntimePolicy",
332                    crate::config::DEFAULT_PROJECT_ID,
333                    "runtime"
334                )
335                .is_some()
336        );
337        // Not in _system
338        assert!(store.get("RuntimePolicy", "runtime").is_none());
339    }
340
341    #[test]
342    fn put_detects_metadata_change_as_configured() {
343        let mut store = ResourceStore::default();
344        let cr1 = make_cr("Agent", "a", serde_json::json!({"command": "echo x"}));
345        store.put(cr1);
346        let mut cr2 = make_cr("Agent", "a", serde_json::json!({"command": "echo x"}));
347        cr2.metadata.labels = Some([("env".to_string(), "prod".to_string())].into());
348        assert_eq!(store.put(cr2), ApplyResult::Configured);
349    }
350}