Skip to main content

agent_orchestrator/resource/
workspace.rs

1use crate::cli_types::{OrchestratorResource, ResourceKind, ResourceSpec, WorkspaceSpec};
2use crate::config::{OrchestratorConfig, WorkspaceConfig};
3use anyhow::{Result, anyhow};
4
5use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
6
7#[derive(Debug, Clone)]
8/// Builtin manifest adapter for `Workspace` resources.
9pub struct WorkspaceResource {
10    /// Resource metadata from the manifest.
11    pub metadata: ResourceMetadata,
12    /// Manifest spec payload for the workspace.
13    pub spec: WorkspaceSpec,
14}
15
16impl Resource for WorkspaceResource {
17    fn kind(&self) -> ResourceKind {
18        ResourceKind::Workspace
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.root_path.trim().is_empty() {
28            return Err(anyhow!("workspace.spec.root_path cannot be empty"));
29        }
30        if self.spec.ticket_dir.trim().is_empty() {
31            return Err(anyhow!("workspace.spec.ticket_dir cannot be empty"));
32        }
33        Ok(())
34    }
35
36    fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
37        let mut metadata = self.metadata.clone();
38        metadata.project = Some(
39            config
40                .effective_project_id(metadata.project.as_deref())
41                .to_string(),
42        );
43        Ok(super::apply_to_store(
44            config,
45            "Workspace",
46            self.name(),
47            &metadata,
48            serde_json::to_value(&self.spec)?,
49        ))
50    }
51
52    fn to_yaml(&self) -> Result<String> {
53        super::manifest_yaml(
54            ResourceKind::Workspace,
55            &self.metadata,
56            ResourceSpec::Workspace(self.spec.clone()),
57        )
58    }
59
60    fn get_from_project(
61        config: &OrchestratorConfig,
62        name: &str,
63        project_id: Option<&str>,
64    ) -> Option<Self> {
65        config
66            .project(project_id)?
67            .workspaces
68            .get(name)
69            .map(|workspace| Self {
70                metadata: super::metadata_from_store(config, "Workspace", name, project_id),
71                spec: workspace_config_to_spec(workspace),
72            })
73    }
74
75    fn delete_from_project(
76        config: &mut OrchestratorConfig,
77        name: &str,
78        project_id: Option<&str>,
79    ) -> bool {
80        super::helpers::delete_from_store_project(config, "Workspace", name, project_id)
81    }
82}
83
84/// Builds a typed `WorkspaceResource` from a generic manifest wrapper.
85pub(super) fn build_workspace(resource: OrchestratorResource) -> Result<RegisteredResource> {
86    let OrchestratorResource {
87        kind,
88        metadata,
89        spec,
90        ..
91    } = resource;
92    if kind != ResourceKind::Workspace {
93        return Err(anyhow!("resource kind/spec mismatch for Workspace"));
94    }
95    match spec {
96        ResourceSpec::Workspace(spec) => Ok(RegisteredResource::Workspace(WorkspaceResource {
97            metadata,
98            spec,
99        })),
100        _ => Err(anyhow!("resource kind/spec mismatch for Workspace")),
101    }
102}
103
104/// Converts a workspace manifest spec into runtime config.
105///
106/// Relative `root_path` values are resolved against the current working
107/// directory so that the stored config always contains an absolute path.
108pub(crate) fn workspace_spec_to_config(spec: &WorkspaceSpec) -> WorkspaceConfig {
109    use crate::config::HealthPolicyConfig;
110    let root_path = {
111        let p = std::path::Path::new(&spec.root_path);
112        if p.is_absolute() {
113            spec.root_path.clone()
114        } else {
115            std::env::current_dir()
116                .unwrap_or_else(|_| std::path::PathBuf::from("."))
117                .join(p)
118                .to_string_lossy()
119                .to_string()
120        }
121    };
122    WorkspaceConfig {
123        root_path,
124        qa_targets: spec.qa_targets.clone(),
125        ticket_dir: spec.ticket_dir.clone(),
126        self_referential: spec.self_referential,
127        health_policy: spec
128            .health_policy
129            .as_ref()
130            .map(|hp| HealthPolicyConfig {
131                disease_duration_hours: hp
132                    .disease_duration_hours
133                    .unwrap_or_else(|| HealthPolicyConfig::default().disease_duration_hours),
134                disease_threshold: hp
135                    .disease_threshold
136                    .unwrap_or_else(|| HealthPolicyConfig::default().disease_threshold),
137                capability_success_threshold: hp
138                    .capability_success_threshold
139                    .unwrap_or_else(|| HealthPolicyConfig::default().capability_success_threshold),
140            })
141            .unwrap_or_default(),
142    }
143}
144
145/// Converts runtime workspace config into its manifest spec representation.
146pub(crate) fn workspace_config_to_spec(config: &WorkspaceConfig) -> WorkspaceSpec {
147    use crate::cli_types::HealthPolicySpec;
148    WorkspaceSpec {
149        root_path: config.root_path.clone(),
150        qa_targets: config.qa_targets.clone(),
151        ticket_dir: config.ticket_dir.clone(),
152        self_referential: config.self_referential,
153        health_policy: if config.health_policy.is_default() {
154            None
155        } else {
156            Some(HealthPolicySpec {
157                disease_duration_hours: Some(config.health_policy.disease_duration_hours),
158                disease_threshold: Some(config.health_policy.disease_threshold),
159                capability_success_threshold: Some(
160                    config.health_policy.capability_success_threshold,
161                ),
162            })
163        },
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::cli_types::{ResourceMetadata, ResourceSpec};
171    use crate::config_load::read_active_config;
172    use crate::resource::{API_VERSION, dispatch_resource};
173    use crate::test_utils::TestState;
174
175    use super::super::test_fixtures::{make_config, workspace_manifest};
176
177    #[test]
178    fn workspace_resource_apply() {
179        let mut fixture = TestState::new();
180        let state = fixture.build();
181        let mut config = {
182            let active = read_active_config(&state).expect("state should be readable");
183            active.config.clone()
184        };
185
186        let resource =
187            dispatch_resource(workspace_manifest("ws-roundtrip", "workspace/ws-roundtrip"))
188                .expect("workspace dispatch should succeed");
189        assert_eq!(
190            resource.apply(&mut config).expect("apply"),
191            ApplyResult::Created
192        );
193
194        let loaded = WorkspaceResource::get_from(&config, "ws-roundtrip")
195            .expect("workspace should be present in config");
196        // root_path is absolutized at apply-time against CWD
197        assert!(
198            std::path::Path::new(&loaded.spec.root_path).is_absolute(),
199            "root_path should be absolute after apply: {}",
200            loaded.spec.root_path
201        );
202        assert!(
203            loaded.spec.root_path.ends_with("workspace/ws-roundtrip"),
204            "root_path should end with original relative path: {}",
205            loaded.spec.root_path
206        );
207        assert_eq!(loaded.kind(), ResourceKind::Workspace);
208    }
209
210    #[test]
211    fn workspace_validate_rejects_empty_root_path() {
212        let ws = WorkspaceResource {
213            metadata: super::super::metadata_with_name("ws-no-root"),
214            spec: WorkspaceSpec {
215                root_path: "  ".to_string(),
216                qa_targets: vec![],
217                ticket_dir: "tickets".to_string(),
218                self_referential: false,
219                health_policy: None,
220            },
221        };
222        let err = ws.validate().expect_err("operation should fail");
223        assert!(err.to_string().contains("root_path"));
224    }
225
226    #[test]
227    fn workspace_validate_rejects_empty_ticket_dir() {
228        let ws = WorkspaceResource {
229            metadata: super::super::metadata_with_name("ws-no-ticket"),
230            spec: WorkspaceSpec {
231                root_path: "/some/path".to_string(),
232                qa_targets: vec![],
233                ticket_dir: "  ".to_string(),
234                self_referential: false,
235                health_policy: None,
236            },
237        };
238        let err = ws.validate().expect_err("operation should fail");
239        assert!(err.to_string().contains("ticket_dir"));
240    }
241
242    #[test]
243    fn workspace_get_from_without_stored_metadata() {
244        let mut config = make_config();
245        // Insert workspace directly without resource_meta
246        config.ensure_project(None).workspaces.insert(
247            "bare-ws".to_string(),
248            WorkspaceConfig {
249                root_path: "/bare".to_string(),
250                qa_targets: vec![],
251                ticket_dir: "t".to_string(),
252                self_referential: false,
253                health_policy: Default::default(),
254            },
255        );
256        let loaded = WorkspaceResource::get_from(&config, "bare-ws")
257            .expect("bare workspace should be returned");
258        assert_eq!(loaded.metadata.name, "bare-ws");
259        assert!(loaded.metadata.labels.is_none());
260    }
261
262    #[test]
263    fn workspace_get_from_returns_none_for_missing() {
264        let config = make_config();
265        assert!(WorkspaceResource::get_from(&config, "nonexistent-ws").is_none());
266    }
267
268    #[test]
269    fn workspace_delete_cleans_up_metadata() {
270        let mut config = make_config();
271        let resource = OrchestratorResource {
272            api_version: API_VERSION.to_string(),
273            kind: ResourceKind::Workspace,
274            metadata: ResourceMetadata {
275                name: "meta-ws".to_string(),
276                project: None,
277                labels: Some([("k".to_string(), "v".to_string())].into()),
278                annotations: None,
279            },
280            spec: ResourceSpec::Workspace(WorkspaceSpec {
281                root_path: "/meta".to_string(),
282                qa_targets: vec![],
283                ticket_dir: "t".to_string(),
284                self_referential: false,
285                health_policy: None,
286            }),
287        };
288        let rr = dispatch_resource(resource).expect("dispatch workspace resource");
289        rr.apply(&mut config).expect("apply");
290        assert!(
291            config
292                .resource_store
293                .get_namespaced("Workspace", crate::config::DEFAULT_PROJECT_ID, "meta-ws")
294                .is_some()
295        );
296
297        WorkspaceResource::delete_from(&mut config, "meta-ws");
298        assert!(
299            config
300                .resource_store
301                .get_namespaced("Workspace", crate::config::DEFAULT_PROJECT_ID, "meta-ws")
302                .is_none()
303        );
304    }
305
306    #[test]
307    fn workspace_to_yaml_includes_all_fields() {
308        let workspace = WorkspaceResource {
309            metadata: ResourceMetadata {
310                name: "full-workspace".to_string(),
311                project: None,
312                labels: Some([("env".to_string(), "test".to_string())].into()),
313                annotations: Some([("desc".to_string(), "test workspace".to_string())].into()),
314            },
315            spec: WorkspaceSpec {
316                root_path: "/path/to/workspace".to_string(),
317                qa_targets: vec!["docs/qa".to_string(), "tests".to_string()],
318                ticket_dir: "tickets".to_string(),
319                self_referential: false,
320                health_policy: None,
321            },
322        };
323        let yaml = workspace.to_yaml().expect("should serialize");
324        assert!(yaml.contains("full-workspace"));
325        assert!(yaml.contains("/path/to/workspace"));
326        assert!(yaml.contains("docs/qa"));
327        assert!(yaml.contains("tickets"));
328    }
329
330    #[test]
331    fn workspace_spec_config_roundtrip() {
332        let spec = WorkspaceSpec {
333            root_path: "/my/project".to_string(),
334            qa_targets: vec!["src".to_string(), "tests".to_string()],
335            ticket_dir: "docs/tickets".to_string(),
336            self_referential: true,
337            health_policy: None,
338        };
339        let config = workspace_spec_to_config(&spec);
340        assert_eq!(config.root_path, "/my/project");
341        assert_eq!(config.qa_targets, vec!["src", "tests"]);
342        assert_eq!(config.ticket_dir, "docs/tickets");
343        assert!(config.self_referential);
344
345        let back = workspace_config_to_spec(&config);
346        assert_eq!(back.root_path, "/my/project");
347        assert_eq!(back.qa_targets, vec!["src", "tests"]);
348        assert!(back.self_referential);
349    }
350
351    #[test]
352    fn workspace_apply_stores_resource_metadata() {
353        let mut config = make_config();
354        let resource = OrchestratorResource {
355            api_version: API_VERSION.to_string(),
356            kind: ResourceKind::Workspace,
357            metadata: ResourceMetadata {
358                name: "store-meta-ws".to_string(),
359                project: None,
360                labels: Some([("env".to_string(), "staging".to_string())].into()),
361                annotations: Some([("note".to_string(), "test".to_string())].into()),
362            },
363            spec: ResourceSpec::Workspace(WorkspaceSpec {
364                root_path: "/store-meta".to_string(),
365                qa_targets: vec![],
366                ticket_dir: "t".to_string(),
367                self_referential: false,
368                health_policy: None,
369            }),
370        };
371        let rr = dispatch_resource(resource).expect("dispatch workspace resource");
372        rr.apply(&mut config).expect("apply");
373
374        let cr = config
375            .resource_store
376            .get_namespaced(
377                "Workspace",
378                crate::config::DEFAULT_PROJECT_ID,
379                "store-meta-ws",
380            )
381            .expect("stored workspace CR should exist");
382        assert_eq!(
383            cr.metadata
384                .labels
385                .as_ref()
386                .expect("labels should exist")
387                .get("env")
388                .expect("env label should exist"),
389            "staging"
390        );
391        assert_eq!(
392            cr.metadata
393                .annotations
394                .as_ref()
395                .expect("annotations should exist")
396                .get("note")
397                .expect("note annotation should exist"),
398            "test"
399        );
400    }
401}