1use anyhow::Result;
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4
5pub trait CrdProjectable: Sized + Serialize + DeserializeOwned {
10 fn crd_kind() -> &'static str;
12
13 fn from_cr_spec(spec: &serde_json::Value) -> Result<Self>;
15
16 fn to_cr_spec(&self) -> serde_json::Value;
18}
19
20use 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#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
117pub struct RuntimePolicyProjection {
118 #[serde(default)]
119 pub runner: RunnerConfig,
121 #[serde(default)]
122 pub resume: ResumeConfig,
124 #[serde(default)]
125 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 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 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}