Skip to main content

agent_orchestrator/resource/
step_template.rs

1use crate::cli_types::{OrchestratorResource, ResourceKind, ResourceSpec, StepTemplateSpec};
2use crate::config::{OrchestratorConfig, StepTemplateConfig};
3use anyhow::{Result, anyhow};
4
5use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
6
7#[derive(Debug, Clone)]
8/// Builtin manifest adapter for `StepTemplate` resources.
9pub struct StepTemplateResource {
10    /// Resource metadata from the manifest.
11    pub metadata: ResourceMetadata,
12    /// Manifest spec payload for the step template.
13    pub spec: StepTemplateSpec,
14}
15
16impl Resource for StepTemplateResource {
17    fn kind(&self) -> ResourceKind {
18        ResourceKind::StepTemplate
19    }
20
21    fn name(&self) -> &str {
22        &self.metadata.name
23    }
24
25    fn validate(&self) -> Result<()> {
26        super::validate_resource_name(self.name())?;
27        if self.spec.prompt.trim().is_empty() {
28            return Err(anyhow!("step_template.spec.prompt cannot be empty"));
29        }
30        Ok(())
31    }
32
33    fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
34        let incoming = StepTemplateConfig {
35            prompt: self.spec.prompt.clone(),
36            description: self.spec.description.clone(),
37        };
38        let project = config.ensure_project(self.metadata.project.as_deref());
39        Ok(super::helpers::apply_to_map(
40            &mut project.step_templates,
41            self.name(),
42            incoming,
43        ))
44    }
45
46    fn to_yaml(&self) -> Result<String> {
47        super::manifest_yaml(
48            ResourceKind::StepTemplate,
49            &self.metadata,
50            ResourceSpec::StepTemplate(self.spec.clone()),
51        )
52    }
53
54    fn get_from_project(
55        config: &OrchestratorConfig,
56        name: &str,
57        project_id: Option<&str>,
58    ) -> Option<Self> {
59        config
60            .project(project_id)?
61            .step_templates
62            .get(name)
63            .map(|tmpl| Self {
64                metadata: super::metadata_with_name(name),
65                spec: StepTemplateSpec {
66                    prompt: tmpl.prompt.clone(),
67                    description: tmpl.description.clone(),
68                },
69            })
70    }
71
72    fn delete_from_project(
73        config: &mut OrchestratorConfig,
74        name: &str,
75        project_id: Option<&str>,
76    ) -> bool {
77        config
78            .project_mut(project_id)
79            .map(|project| project.step_templates.remove(name).is_some())
80            .unwrap_or(false)
81    }
82}
83
84/// Builds a typed `StepTemplateResource` from a generic manifest wrapper.
85pub(super) fn build_step_template(resource: OrchestratorResource) -> Result<RegisteredResource> {
86    let OrchestratorResource {
87        kind,
88        metadata,
89        spec,
90        ..
91    } = resource;
92    if kind != ResourceKind::StepTemplate {
93        return Err(anyhow!("resource kind/spec mismatch for StepTemplate"));
94    }
95    match spec {
96        ResourceSpec::StepTemplate(spec) => {
97            Ok(RegisteredResource::StepTemplate(StepTemplateResource {
98                metadata,
99                spec,
100            }))
101        }
102        _ => Err(anyhow!("resource kind/spec mismatch for StepTemplate")),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::resource::dispatch_resource;
110
111    use super::super::test_fixtures::{make_config, step_template_manifest};
112
113    #[test]
114    fn step_template_dispatch_and_kind() {
115        let resource = dispatch_resource(step_template_manifest("plan", "You are a planner."))
116            .expect("dispatch should succeed");
117        assert_eq!(resource.kind(), ResourceKind::StepTemplate);
118        assert_eq!(resource.name(), "plan");
119    }
120
121    #[test]
122    fn step_template_validate_accepts_valid() {
123        let resource = dispatch_resource(step_template_manifest("plan", "You are a planner."))
124            .expect("dispatch should succeed");
125        assert!(resource.validate().is_ok());
126    }
127
128    #[test]
129    fn step_template_validate_rejects_empty_name() {
130        let resource = dispatch_resource(step_template_manifest("", "prompt"))
131            .expect("dispatch should succeed");
132        assert!(resource.validate().is_err());
133    }
134
135    #[test]
136    fn step_template_validate_rejects_empty_prompt() {
137        let tmpl = StepTemplateResource {
138            metadata: super::super::metadata_with_name("empty-prompt"),
139            spec: StepTemplateSpec {
140                prompt: "  ".to_string(),
141                description: None,
142            },
143        };
144        let err = tmpl.validate().expect_err("should reject empty prompt");
145        assert!(err.to_string().contains("prompt cannot be empty"));
146    }
147
148    #[test]
149    fn step_template_apply_created_then_unchanged() {
150        let mut config = make_config();
151        let resource = dispatch_resource(step_template_manifest("plan", "You are a planner."))
152            .expect("dispatch should succeed");
153        assert_eq!(
154            resource.apply(&mut config).expect("apply"),
155            ApplyResult::Created
156        );
157        assert_eq!(
158            resource.apply(&mut config).expect("apply"),
159            ApplyResult::Unchanged
160        );
161    }
162
163    #[test]
164    fn step_template_apply_configured_on_change() {
165        let mut config = make_config();
166        let r1 = dispatch_resource(step_template_manifest("plan", "v1"))
167            .expect("dispatch should succeed");
168        assert_eq!(r1.apply(&mut config).expect("apply"), ApplyResult::Created);
169        let r2 = dispatch_resource(step_template_manifest("plan", "v2"))
170            .expect("dispatch should succeed");
171        assert_eq!(
172            r2.apply(&mut config).expect("apply"),
173            ApplyResult::Configured
174        );
175    }
176
177    #[test]
178    fn step_template_get_from_and_delete_from() {
179        let mut config = make_config();
180        let resource = dispatch_resource(step_template_manifest("plan", "prompt text"))
181            .expect("dispatch should succeed");
182        resource.apply(&mut config).expect("apply");
183
184        let loaded = StepTemplateResource::get_from(&config, "plan");
185        let loaded = loaded.expect("should be found after apply");
186        assert_eq!(loaded.spec.prompt, "prompt text");
187
188        assert!(StepTemplateResource::delete_from(&mut config, "plan"));
189        assert!(StepTemplateResource::get_from(&config, "plan").is_none());
190    }
191
192    #[test]
193    fn step_template_to_yaml() {
194        let resource = dispatch_resource(step_template_manifest("plan", "You are a planner."))
195            .expect("dispatch should succeed");
196        let yaml = resource.to_yaml().expect("should serialize");
197        assert!(yaml.contains("kind: StepTemplate"));
198        assert!(yaml.contains("plan"));
199        assert!(yaml.contains("planner"));
200    }
201
202    #[test]
203    fn step_template_yaml_roundtrip() {
204        let yaml = r#"
205apiVersion: orchestrator.dev/v2
206kind: StepTemplate
207metadata:
208  name: plan
209spec:
210  prompt: "You are a planner for {source_tree}."
211  description: "Planning template"
212"#;
213        let resource: OrchestratorResource = serde_yaml::from_str(yaml).expect("should parse YAML");
214        resource
215            .validate_version()
216            .expect("version should be valid");
217        assert_eq!(resource.kind, ResourceKind::StepTemplate);
218        if let ResourceSpec::StepTemplate(ref spec) = resource.spec {
219            assert!(spec.prompt.contains("planner"));
220            assert_eq!(spec.description.as_deref(), Some("Planning template"));
221        } else {
222            panic!("expected StepTemplate spec");
223        }
224    }
225}