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