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,
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#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
116pub struct RuntimePolicyProjection {
117 #[serde(default)]
118 pub runner: RunnerConfig,
120 #[serde(default)]
121 pub resume: ResumeConfig,
123 #[serde(default)]
124 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#[derive(Debug, Clone, Serialize, serde::Deserialize)]
221pub 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 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 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}