Skip to main content

agent_orchestrator/crd/
projection.rs

1use anyhow::Result;
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4
5/// Trait for types that can be projected to/from CRD custom resource specs.
6///
7/// Implemented by each of the 9 builtin config types to enable round-trip
8/// conversion between typed config and `serde_json::Value` spec.
9pub trait CrdProjectable: Sized + Serialize + DeserializeOwned {
10    /// The CRD kind string for this type (e.g. "Agent", "Workflow").
11    fn crd_kind() -> &'static str;
12
13    /// Construct a typed config from a CR spec JSON value.
14    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self>;
15
16    /// Convert a typed config to a CR spec JSON value.
17    fn to_cr_spec(&self) -> serde_json::Value;
18}
19
20// ── Implementations for the 9 builtin config types ───────────────────────────
21
22use crate::cli_types::{
23    AgentSpec, EnvStoreSpec, ExecutionProfileSpec, ProjectSpec, RuntimePolicySpec, SecretStoreSpec,
24    StepTemplateSpec, WorkspaceSpec,
25};
26use crate::config::{
27    AgentConfig, EnvStoreConfig, ExecutionProfileConfig, ProjectConfig, ResumeConfig, RunnerConfig,
28    SecretStoreConfig, StepTemplateConfig, StoreBackendProviderConfig, WorkflowConfig,
29    WorkflowStoreConfig, WorkspaceConfig,
30};
31use crate::resource::agent::{agent_config_to_spec, agent_spec_to_config};
32use crate::resource::execution_profile::{
33    execution_profile_config_to_spec, execution_profile_spec_to_config,
34};
35use crate::resource::runtime_policy::{runner_config_to_spec, runner_spec_to_config};
36use crate::resource::workflow::{workflow_config_to_spec, workflow_spec_to_config};
37use crate::resource::workspace::{workspace_config_to_spec, workspace_spec_to_config};
38
39impl CrdProjectable for AgentConfig {
40    fn crd_kind() -> &'static str {
41        "Agent"
42    }
43
44    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
45        let agent_spec: AgentSpec = serde_json::from_value(spec.clone())?;
46        Ok(agent_spec_to_config(&agent_spec))
47    }
48
49    fn to_cr_spec(&self) -> serde_json::Value {
50        let spec = agent_config_to_spec(self);
51        serde_json::to_value(&spec).unwrap_or_default()
52    }
53}
54
55impl CrdProjectable for WorkflowConfig {
56    fn crd_kind() -> &'static str {
57        "Workflow"
58    }
59
60    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
61        let wf_spec: crate::cli_types::WorkflowSpec = serde_json::from_value(spec.clone())?;
62        workflow_spec_to_config(&wf_spec)
63    }
64
65    fn to_cr_spec(&self) -> serde_json::Value {
66        let spec = workflow_config_to_spec(self);
67        serde_json::to_value(&spec).unwrap_or_default()
68    }
69}
70
71impl CrdProjectable for WorkspaceConfig {
72    fn crd_kind() -> &'static str {
73        "Workspace"
74    }
75
76    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
77        let ws_spec: WorkspaceSpec = serde_json::from_value(spec.clone())?;
78        Ok(workspace_spec_to_config(&ws_spec))
79    }
80
81    fn to_cr_spec(&self) -> serde_json::Value {
82        let spec = workspace_config_to_spec(self);
83        serde_json::to_value(&spec).unwrap_or_default()
84    }
85}
86
87impl CrdProjectable for ProjectConfig {
88    fn crd_kind() -> &'static str {
89        "Project"
90    }
91
92    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
93        let proj_spec: ProjectSpec = serde_json::from_value(spec.clone())?;
94        Ok(ProjectConfig {
95            description: proj_spec.description,
96            workspaces: Default::default(),
97            agents: Default::default(),
98            workflows: Default::default(),
99            step_templates: Default::default(),
100            env_stores: Default::default(),
101            secret_stores: Default::default(),
102            execution_profiles: Default::default(),
103            triggers: Default::default(),
104        })
105    }
106
107    fn to_cr_spec(&self) -> serde_json::Value {
108        let spec = ProjectSpec {
109            description: self.description.clone(),
110        };
111        serde_json::to_value(&spec).unwrap_or_default()
112    }
113}
114
115/// Combined type for RuntimePolicy projection (runner + resume + observability).
116#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
117pub struct RuntimePolicyProjection {
118    #[serde(default)]
119    /// Runner policy configuration.
120    pub runner: RunnerConfig,
121    #[serde(default)]
122    /// Resume policy configuration.
123    pub resume: ResumeConfig,
124    #[serde(default)]
125    /// Observability policy configuration.
126    pub observability: crate::config::ObservabilityConfig,
127}
128
129impl CrdProjectable for RuntimePolicyProjection {
130    fn crd_kind() -> &'static str {
131        "RuntimePolicy"
132    }
133
134    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
135        let rp_spec: RuntimePolicySpec = serde_json::from_value(spec.clone())?;
136        let observability = rp_spec
137            .observability
138            .and_then(|v| serde_json::from_value(v).ok())
139            .unwrap_or_default();
140        Ok(RuntimePolicyProjection {
141            runner: runner_spec_to_config(&rp_spec.runner),
142            resume: ResumeConfig {
143                auto: rp_spec.resume.auto,
144            },
145            observability,
146        })
147    }
148
149    fn to_cr_spec(&self) -> serde_json::Value {
150        let spec = RuntimePolicySpec {
151            runner: runner_config_to_spec(&self.runner),
152            resume: crate::cli_types::ResumeSpec {
153                auto: self.resume.auto,
154            },
155            observability: serde_json::to_value(&self.observability).ok(),
156        };
157        serde_json::to_value(&spec).unwrap_or_default()
158    }
159}
160
161impl CrdProjectable for StepTemplateConfig {
162    fn crd_kind() -> &'static str {
163        "StepTemplate"
164    }
165
166    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
167        let st_spec: StepTemplateSpec = serde_json::from_value(spec.clone())?;
168        Ok(StepTemplateConfig {
169            prompt: st_spec.prompt,
170            description: st_spec.description,
171        })
172    }
173
174    fn to_cr_spec(&self) -> serde_json::Value {
175        let spec = StepTemplateSpec {
176            prompt: self.prompt.clone(),
177            description: self.description.clone(),
178        };
179        serde_json::to_value(&spec).unwrap_or_default()
180    }
181}
182
183impl CrdProjectable for ExecutionProfileConfig {
184    fn crd_kind() -> &'static str {
185        "ExecutionProfile"
186    }
187
188    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
189        let profile_spec: ExecutionProfileSpec = serde_json::from_value(spec.clone())?;
190        Ok(execution_profile_spec_to_config(&profile_spec))
191    }
192
193    fn to_cr_spec(&self) -> serde_json::Value {
194        let spec = execution_profile_config_to_spec(self);
195        serde_json::to_value(&spec).unwrap_or_default()
196    }
197}
198
199impl CrdProjectable for EnvStoreConfig {
200    fn crd_kind() -> &'static str {
201        "EnvStore"
202    }
203
204    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
205        let es_spec: EnvStoreSpec = serde_json::from_value(spec.clone())?;
206        Ok(EnvStoreConfig { data: es_spec.data })
207    }
208
209    fn to_cr_spec(&self) -> serde_json::Value {
210        let spec = EnvStoreSpec {
211            data: self.data.clone(),
212        };
213        serde_json::to_value(&spec).unwrap_or_default()
214    }
215}
216
217impl CrdProjectable for SecretStoreConfig {
218    fn crd_kind() -> &'static str {
219        "SecretStore"
220    }
221
222    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
223        let ss_spec: SecretStoreSpec = serde_json::from_value(spec.clone())?;
224        Ok(SecretStoreConfig { data: ss_spec.data })
225    }
226
227    fn to_cr_spec(&self) -> serde_json::Value {
228        let spec = SecretStoreSpec {
229            data: self.data.clone(),
230        };
231        serde_json::to_value(&spec).unwrap_or_default()
232    }
233}
234
235impl CrdProjectable for WorkflowStoreConfig {
236    fn crd_kind() -> &'static str {
237        "WorkflowStore"
238    }
239
240    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
241        Ok(serde_json::from_value(spec.clone())?)
242    }
243
244    fn to_cr_spec(&self) -> serde_json::Value {
245        serde_json::to_value(self).unwrap_or_default()
246    }
247}
248
249impl CrdProjectable for StoreBackendProviderConfig {
250    fn crd_kind() -> &'static str {
251        "StoreBackendProvider"
252    }
253
254    fn from_cr_spec(spec: &serde_json::Value) -> Result<Self> {
255        Ok(serde_json::from_value(spec.clone())?)
256    }
257
258    fn to_cr_spec(&self) -> serde_json::Value {
259        serde_json::to_value(self).unwrap_or_default()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn agent_config_round_trip() {
269        let config = AgentConfig {
270            enabled: true,
271            command: "echo {prompt}".to_string(),
272            capabilities: vec!["plan".to_string()],
273            ..Default::default()
274        };
275        let spec = config.to_cr_spec();
276        let back = AgentConfig::from_cr_spec(&spec).expect("should deserialize");
277        assert_eq!(back.command, "echo {prompt}");
278        assert!(back.capabilities.contains(&"plan".to_string()));
279    }
280
281    #[test]
282    fn workspace_config_round_trip() {
283        let config = WorkspaceConfig {
284            root_path: "/test".to_string(),
285            qa_targets: vec!["src".to_string()],
286            ticket_dir: "tickets".to_string(),
287            self_referential: false,
288            health_policy: Default::default(),
289        };
290        let spec = config.to_cr_spec();
291        let back = WorkspaceConfig::from_cr_spec(&spec).expect("should deserialize");
292        assert_eq!(back.root_path, "/test");
293        assert_eq!(back.qa_targets, vec!["src"]);
294    }
295
296    #[test]
297    fn step_template_config_round_trip() {
298        let config = StepTemplateConfig {
299            prompt: "Do qa".to_string(),
300            description: Some("QA template".to_string()),
301        };
302        let spec = config.to_cr_spec();
303        let back = StepTemplateConfig::from_cr_spec(&spec).expect("should deserialize");
304        assert_eq!(back.prompt, "Do qa");
305        assert_eq!(back.description, Some("QA template".to_string()));
306    }
307
308    #[test]
309    fn env_store_config_round_trip() {
310        let config = EnvStoreConfig {
311            data: [("K".to_string(), "V".to_string())].into(),
312        };
313        let spec = config.to_cr_spec();
314        let back = EnvStoreConfig::from_cr_spec(&spec).expect("should deserialize");
315        assert_eq!(back.data.get("K").unwrap(), "V");
316    }
317
318    #[test]
319    fn secret_store_config_round_trip() {
320        let config = SecretStoreConfig {
321            data: [("SECRET".to_string(), "val".to_string())].into(),
322        };
323        let spec = config.to_cr_spec();
324        let back = SecretStoreConfig::from_cr_spec(&spec).expect("should deserialize");
325        assert_eq!(back.data.get("SECRET").unwrap(), "val");
326    }
327
328    #[test]
329    fn runtime_policy_projection_round_trip() {
330        let config = RuntimePolicyProjection {
331            runner: RunnerConfig::default(),
332            resume: ResumeConfig { auto: true },
333            observability: crate::config::ObservabilityConfig::default(),
334        };
335        let spec = config.to_cr_spec();
336        let back = RuntimePolicyProjection::from_cr_spec(&spec).expect("should deserialize");
337        assert!(back.resume.auto);
338        assert_eq!(back.runner.shell, "/bin/bash");
339    }
340
341    #[test]
342    fn project_config_round_trip() {
343        let config = ProjectConfig {
344            description: Some("test project".to_string()),
345            workspaces: Default::default(),
346            agents: Default::default(),
347            workflows: Default::default(),
348            step_templates: Default::default(),
349            env_stores: Default::default(),
350            secret_stores: Default::default(),
351            execution_profiles: Default::default(),
352            triggers: Default::default(),
353        };
354        let spec = config.to_cr_spec();
355        let back = ProjectConfig::from_cr_spec(&spec).expect("should deserialize");
356        assert_eq!(back.description, Some("test project".to_string()));
357        // Nested maps are not preserved through projection — that's expected
358        assert!(back.workspaces.is_empty());
359    }
360
361    #[test]
362    fn workflow_config_round_trip() {
363        use crate::config::{
364            LoopMode, StepBehavior, WorkflowFinalizeConfig, WorkflowLoopConfig,
365            WorkflowLoopGuardConfig, WorkflowStepConfig,
366        };
367        let config = WorkflowConfig {
368            steps: vec![
369                WorkflowStepConfig {
370                    id: "plan".to_string(),
371                    description: Some("Planning step".to_string()),
372                    required_capability: Some("plan".to_string()),
373                    execution_profile: None,
374                    builtin: None,
375                    enabled: true,
376                    repeatable: false,
377                    is_guard: false,
378                    cost_preference: None,
379                    prehook: None,
380                    tty: false,
381                    template: None,
382                    outputs: vec![],
383                    pipe_to: None,
384                    command: None,
385                    chain_steps: vec![],
386                    scope: None,
387                    behavior: StepBehavior::default(),
388                    max_parallel: None,
389                    stagger_delay_ms: None,
390                    timeout_secs: None,
391                    stall_timeout_secs: None,
392                    item_select_config: None,
393                    store_inputs: vec![],
394                    store_outputs: vec![],
395                    step_vars: None,
396                },
397                WorkflowStepConfig {
398                    id: "self_test".to_string(),
399                    description: None,
400                    required_capability: None,
401                    execution_profile: None,
402                    builtin: Some("self_test".to_string()),
403                    enabled: true,
404                    repeatable: false,
405                    is_guard: false,
406                    cost_preference: None,
407                    prehook: None,
408                    tty: false,
409                    template: None,
410                    outputs: vec![],
411                    pipe_to: None,
412                    command: None,
413                    chain_steps: vec![],
414                    scope: None,
415                    behavior: StepBehavior::default(),
416                    max_parallel: None,
417                    stagger_delay_ms: None,
418                    timeout_secs: None,
419                    stall_timeout_secs: None,
420                    item_select_config: None,
421                    store_inputs: vec![],
422                    store_outputs: vec![],
423                    step_vars: None,
424                },
425            ],
426            execution: Default::default(),
427            loop_policy: WorkflowLoopConfig {
428                mode: LoopMode::Fixed,
429                guard: WorkflowLoopGuardConfig {
430                    enabled: true,
431                    ..WorkflowLoopGuardConfig::default()
432                },
433                convergence_expr: None,
434            },
435            finalize: WorkflowFinalizeConfig { rules: vec![] },
436            qa: None,
437            fix: None,
438            retest: None,
439            dynamic_steps: vec![],
440            adaptive: None,
441            safety: crate::config::SafetyConfig::default(),
442            max_parallel: None,
443            stagger_delay_ms: None,
444            item_isolation: None,
445        };
446        let spec = config.to_cr_spec();
447        let back = WorkflowConfig::from_cr_spec(&spec).expect("should deserialize workflow");
448        assert_eq!(back.steps.len(), 2);
449
450        let plan_step = back
451            .steps
452            .iter()
453            .find(|s| s.id == "plan")
454            .expect("plan step");
455        assert_eq!(plan_step.required_capability.as_deref(), Some("plan"));
456        assert!(plan_step.enabled);
457
458        let builtin_step = back
459            .steps
460            .iter()
461            .find(|s| s.id == "self_test")
462            .expect("self_test step");
463        assert_eq!(builtin_step.builtin.as_deref(), Some("self_test"));
464    }
465
466    #[test]
467    fn workflow_config_round_trip_preserves_loop_mode() {
468        use crate::config::{
469            LoopMode, WorkflowFinalizeConfig, WorkflowLoopConfig, WorkflowLoopGuardConfig,
470        };
471        let config = WorkflowConfig {
472            steps: vec![],
473            execution: Default::default(),
474            loop_policy: WorkflowLoopConfig {
475                mode: LoopMode::Fixed,
476                guard: WorkflowLoopGuardConfig::default(),
477                convergence_expr: None,
478            },
479            finalize: WorkflowFinalizeConfig { rules: vec![] },
480            qa: None,
481            fix: None,
482            retest: None,
483            dynamic_steps: vec![],
484            adaptive: None,
485            safety: crate::config::SafetyConfig::default(),
486            max_parallel: None,
487            stagger_delay_ms: None,
488            item_isolation: None,
489        };
490        let spec = config.to_cr_spec();
491        let back = WorkflowConfig::from_cr_spec(&spec).expect("should deserialize");
492        assert!(matches!(back.loop_policy.mode, LoopMode::Fixed));
493    }
494
495    #[test]
496    fn from_cr_spec_rejects_malformed_agent_spec() {
497        let bad_spec = serde_json::json!({ "not_a_valid_field": 42 });
498        // AgentSpec requires "command" field — absence should cause deserialization error
499        let result = AgentConfig::from_cr_spec(&bad_spec);
500        assert!(
501            result.is_err(),
502            "should reject spec missing required 'command' field"
503        );
504    }
505
506    #[test]
507    fn all_eleven_kinds_are_unique() {
508        let kinds = [
509            AgentConfig::crd_kind(),
510            WorkflowConfig::crd_kind(),
511            WorkspaceConfig::crd_kind(),
512            ProjectConfig::crd_kind(),
513            RuntimePolicyProjection::crd_kind(),
514            StepTemplateConfig::crd_kind(),
515            EnvStoreConfig::crd_kind(),
516            SecretStoreConfig::crd_kind(),
517            WorkflowStoreConfig::crd_kind(),
518            StoreBackendProviderConfig::crd_kind(),
519        ];
520        let mut set = std::collections::HashSet::new();
521        for kind in &kinds {
522            assert!(set.insert(*kind), "duplicate kind: {}", kind);
523        }
524        assert_eq!(set.len(), 10);
525    }
526
527    #[test]
528    fn workflow_store_config_round_trip() {
529        let config = WorkflowStoreConfig {
530            provider: "redis".to_string(),
531            base_path: None,
532            schema: Some(serde_json::json!({"type": "object"})),
533            retention: crate::config::StoreRetention {
534                max_entries: Some(200),
535                ttl_days: Some(90),
536            },
537        };
538        let spec = config.to_cr_spec();
539        let back = WorkflowStoreConfig::from_cr_spec(&spec).expect("should deserialize");
540        assert_eq!(back.provider, "redis");
541        assert_eq!(back.retention.max_entries, Some(200));
542    }
543
544    #[test]
545    fn store_backend_provider_config_round_trip() {
546        let config = StoreBackendProviderConfig {
547            builtin: false,
548            commands: Some(crate::config::StoreBackendCommands {
549                get: "redis-cli GET $KEY".to_string(),
550                put: "redis-cli SET $KEY $VALUE".to_string(),
551                delete: "redis-cli DEL $KEY".to_string(),
552                list: "redis-cli KEYS *".to_string(),
553                prune: None,
554            }),
555        };
556        let spec = config.to_cr_spec();
557        let back = StoreBackendProviderConfig::from_cr_spec(&spec).expect("should deserialize");
558        assert!(!back.builtin);
559        assert_eq!(
560            back.commands.as_ref().map(|c| c.get.as_str()),
561            Some("redis-cli GET $KEY")
562        );
563    }
564}