agent_orchestrator/resource/
workspace.rs1use 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)]
8pub struct WorkspaceResource {
10 pub metadata: ResourceMetadata,
12 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
84pub(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
104pub(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
145pub(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 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 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}