1use serde::{Deserialize, Serialize};
2
3use super::{GenerateItemsAction, SpawnTaskAction, SpawnTasksAction, WorkflowStepConfig};
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
7#[serde(rename_all = "snake_case")]
8pub enum StepScope {
9 Task,
11 #[default]
13 Item,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
20pub struct StepBehavior {
21 #[serde(default)]
23 pub on_failure: OnFailureAction,
24 #[serde(default)]
26 pub on_success: OnSuccessAction,
27 #[serde(default)]
29 pub captures: Vec<CaptureDecl>,
30 #[serde(default)]
32 pub post_actions: Vec<PostAction>,
33 #[serde(default)]
35 pub execution: ExecutionMode,
36 #[serde(default)]
38 pub collect_artifacts: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
43#[serde(tag = "action", rename_all = "snake_case")]
44pub enum OnFailureAction {
45 #[default]
47 Continue,
48 SetStatus {
50 status: String,
52 },
53 EarlyReturn {
55 status: String,
57 },
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
62#[serde(tag = "action", rename_all = "snake_case")]
63pub enum OnSuccessAction {
64 #[default]
66 Continue,
67 SetStatus {
69 status: String,
71 },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct CaptureDecl {
77 pub var: String,
79 pub source: CaptureSource,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub json_path: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "snake_case")]
89pub enum CaptureSource {
90 Stdout,
92 Stderr,
94 ExitCode,
96 FailedFlag,
98 SuccessFlag,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case", tag = "type")]
105pub enum PostAction {
106 CreateTicket,
108 ScanTickets,
110 SpawnTask(SpawnTaskAction),
112 SpawnTasks(SpawnTasksAction),
114 GenerateItems(GenerateItemsAction),
116 StorePut {
118 store: String,
120 key: String,
122 from_var: String,
124 },
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
129#[serde(rename_all = "snake_case", tag = "type")]
130pub enum ExecutionMode {
131 #[default]
133 Agent,
134 Builtin {
136 name: String,
138 },
139 Chain,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum StepSemanticKind {
146 Builtin {
148 name: String,
150 },
151 Agent {
153 capability: String,
155 },
156 Command,
158 Chain,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
164#[serde(rename_all = "snake_case")]
165pub enum CostPreference {
166 Performance,
168 Quality,
170 #[default]
171 Balance,
173}
174
175const KNOWN_STEP_IDS: &[&str] = &[
177 "init_once",
178 "plan",
179 "qa",
180 "ticket_scan",
181 "fix",
182 "retest",
183 "loop_guard",
184 "build",
185 "test",
186 "lint",
187 "implement",
188 "review",
189 "git_ops",
190 "qa_doc_gen",
191 "qa_testing",
192 "ticket_fix",
193 "doc_governance",
194 "align_tests",
195 "self_test",
196 "self_restart",
197 "smoke_chain",
198 "evaluate",
199 "item_select",
200];
201
202const KNOWN_BUILTIN_STEP_NAMES: &[&str] = &[
203 "init_once",
204 "loop_guard",
205 "ticket_scan",
206 "self_test",
207 "self_restart",
208 "item_select",
209];
210
211pub fn validate_step_type(value: &str) -> Result<String, String> {
213 if KNOWN_STEP_IDS.contains(&value) {
214 Ok(value.to_string())
215 } else {
216 Err(format!("unknown workflow step type: {}", value))
217 }
218}
219
220pub fn is_known_builtin_step_name(value: &str) -> bool {
222 KNOWN_BUILTIN_STEP_NAMES.contains(&value)
223}
224
225pub fn default_builtin_for_step_id(step_id: &str) -> Option<&'static str> {
227 match step_id {
228 "init_once" => Some("init_once"),
229 "loop_guard" => Some("loop_guard"),
230 "ticket_scan" => Some("ticket_scan"),
231 "self_test" => Some("self_test"),
232 "self_restart" => Some("self_restart"),
233 "item_select" => Some("item_select"),
234 _ => None,
235 }
236}
237
238pub fn default_required_capability_for_step_id(step_id: &str) -> Option<&'static str> {
240 match step_id {
241 "qa" => Some("qa"),
242 "fix" => Some("fix"),
243 "retest" => Some("retest"),
244 "plan" => Some("plan"),
245 "build" => Some("build"),
246 "test" => Some("test"),
247 "lint" => Some("lint"),
248 "implement" => Some("implement"),
249 "review" => Some("review"),
250 "git_ops" => Some("git_ops"),
251 "qa_doc_gen" => Some("qa_doc_gen"),
252 "qa_testing" => Some("qa_testing"),
253 "ticket_fix" => Some("ticket_fix"),
254 "doc_governance" => Some("doc_governance"),
255 "align_tests" => Some("align_tests"),
256 "smoke_chain" => Some("smoke_chain"),
257 "evaluate" => Some("evaluate"),
258 _ => None,
259 }
260}
261
262pub fn resolve_step_semantic_kind(step: &WorkflowStepConfig) -> Result<StepSemanticKind, String> {
264 if step.builtin.is_some() && step.required_capability.is_some() {
265 return Err(format!(
266 "step '{}' cannot define both builtin and required_capability",
267 step.id
268 ));
269 }
270
271 if !step.chain_steps.is_empty() {
272 return Ok(StepSemanticKind::Chain);
273 }
274
275 if step.command.is_some() {
276 return Ok(StepSemanticKind::Command);
277 }
278
279 if let Some(ref builtin) = step.builtin {
280 if !is_known_builtin_step_name(builtin) {
281 return Err(format!(
282 "step '{}' uses unknown builtin '{}'",
283 step.id, builtin
284 ));
285 }
286 return Ok(StepSemanticKind::Builtin {
287 name: builtin.clone(),
288 });
289 }
290
291 if let Some(ref capability) = step.required_capability {
292 return Ok(StepSemanticKind::Agent {
293 capability: capability.clone(),
294 });
295 }
296
297 if let Some(builtin) = default_builtin_for_step_id(&step.id) {
298 return Ok(StepSemanticKind::Builtin {
299 name: builtin.to_string(),
300 });
301 }
302
303 if let Some(capability) = default_required_capability_for_step_id(&step.id) {
304 return Ok(StepSemanticKind::Agent {
305 capability: capability.to_string(),
306 });
307 }
308
309 Err(format!(
310 "step '{}' is missing builtin, required_capability, command, or chain_steps",
311 step.id
312 ))
313}
314
315pub fn normalize_step_execution_mode(step: &mut WorkflowStepConfig) -> Result<(), String> {
317 match resolve_step_semantic_kind(step)? {
318 StepSemanticKind::Builtin { name } => {
319 step.builtin = Some(name.clone());
320 step.required_capability = None;
321 step.behavior.execution = ExecutionMode::Builtin { name };
322 }
323 StepSemanticKind::Agent { capability } => {
324 step.required_capability = Some(capability);
325 step.behavior.execution = ExecutionMode::Agent;
326 }
327 StepSemanticKind::Command => {
328 step.behavior.execution = ExecutionMode::Builtin {
329 name: step.id.clone(),
330 };
331 }
332 StepSemanticKind::Chain => {
333 step.behavior.execution = ExecutionMode::Chain;
334 }
335 }
336 Ok(())
337}
338
339pub fn has_structured_output(step_id: &str) -> bool {
341 matches!(
342 step_id,
343 "build" | "test" | "lint" | "qa_testing" | "self_test" | "smoke_chain"
344 )
345}
346
347pub fn default_scope_for_step_id(step_id: &str) -> StepScope {
350 match step_id {
351 "qa" | "qa_testing" | "ticket_fix" | "ticket_scan" | "fix" | "retest" => StepScope::Item,
353 _ => StepScope::Task,
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn capture_decl_deserializes_without_json_path() {
364 let capture: CaptureDecl = serde_yaml::from_str(
365 r#"
366var: score
367source: stdout
368"#,
369 )
370 .expect("capture should deserialize");
371
372 assert_eq!(capture.var, "score");
373 assert_eq!(capture.source, CaptureSource::Stdout);
374 assert_eq!(capture.json_path, None);
375 }
376
377 #[test]
378 fn capture_decl_deserializes_with_json_path() {
379 let capture: CaptureDecl = serde_yaml::from_str(
380 r#"
381var: score
382source: stdout
383json_path: $.total_score
384"#,
385 )
386 .expect("capture should deserialize");
387
388 assert_eq!(capture.var, "score");
389 assert_eq!(capture.source, CaptureSource::Stdout);
390 assert_eq!(capture.json_path.as_deref(), Some("$.total_score"));
391 }
392
393 #[test]
394 fn test_validate_step_type_known_ids() {
395 for id in &[
396 "init_once",
397 "plan",
398 "qa",
399 "ticket_scan",
400 "fix",
401 "retest",
402 "loop_guard",
403 "build",
404 "test",
405 "lint",
406 "implement",
407 "review",
408 "git_ops",
409 "qa_doc_gen",
410 "qa_testing",
411 "ticket_fix",
412 "doc_governance",
413 "align_tests",
414 "self_test",
415 "self_restart",
416 "smoke_chain",
417 ] {
418 assert!(validate_step_type(id).is_ok(), "expected valid for {}", id);
419 }
420 }
421
422 #[test]
423 fn test_validate_step_type_unknown_id() {
424 let result = validate_step_type("my_custom_step");
425 assert!(result.is_err());
426 assert!(
427 result
428 .expect_err("operation should fail")
429 .contains("unknown workflow step type")
430 );
431 }
432
433 #[test]
434 fn test_has_structured_output() {
435 assert!(has_structured_output("build"));
436 assert!(has_structured_output("test"));
437 assert!(has_structured_output("lint"));
438 assert!(has_structured_output("qa_testing"));
439 assert!(has_structured_output("self_test"));
440 assert!(has_structured_output("smoke_chain"));
441
442 assert!(!has_structured_output("plan"));
443 assert!(!has_structured_output("fix"));
444 assert!(!has_structured_output("implement"));
445 assert!(!has_structured_output("review"));
446 assert!(!has_structured_output("qa"));
447 assert!(!has_structured_output("doc_governance"));
448 }
449
450 #[test]
451 fn test_default_scope_task_steps() {
452 let task_scoped = vec![
453 "plan",
454 "qa_doc_gen",
455 "implement",
456 "self_test",
457 "align_tests",
458 "doc_governance",
459 "review",
460 "build",
461 "test",
462 "lint",
463 "git_ops",
464 "smoke_chain",
465 "loop_guard",
466 "init_once",
467 ];
468 for id in task_scoped {
469 assert_eq!(
470 default_scope_for_step_id(id),
471 StepScope::Task,
472 "expected Task for {}",
473 id
474 );
475 }
476 }
477
478 #[test]
479 fn test_default_scope_item_steps() {
480 let item_scoped = vec![
481 "qa",
482 "qa_testing",
483 "ticket_fix",
484 "ticket_scan",
485 "fix",
486 "retest",
487 ];
488 for id in item_scoped {
489 assert_eq!(
490 default_scope_for_step_id(id),
491 StepScope::Item,
492 "expected Item for {}",
493 id
494 );
495 }
496 }
497
498 #[test]
499 fn test_step_scope_default() {
500 let scope = StepScope::default();
501 assert_eq!(scope, StepScope::Item);
502 }
503
504 #[test]
505 fn test_cost_preference_default() {
506 let pref = CostPreference::default();
507 assert_eq!(pref, CostPreference::Balance);
508 }
509
510 #[test]
511 fn test_cost_preference_serde_round_trip() {
512 for pref_str in &["\"performance\"", "\"quality\"", "\"balance\""] {
513 let pref: CostPreference =
514 serde_json::from_str(pref_str).expect("deserialize cost preference");
515 let json = serde_json::to_string(&pref).expect("serialize cost preference");
516 assert_eq!(&json, pref_str);
517 }
518 }
519
520 #[test]
521 fn test_step_scope_serde_round_trip() {
522 for scope_str in &["\"task\"", "\"item\""] {
523 let scope: StepScope = serde_json::from_str(scope_str).expect("deserialize step scope");
524 let json = serde_json::to_string(&scope).expect("serialize step scope");
525 assert_eq!(&json, scope_str);
526 }
527 }
528
529 #[test]
530 fn test_post_action_store_put_serde_round_trip() {
531 let action = PostAction::StorePut {
532 store: "metrics".to_string(),
533 key: "bench_result".to_string(),
534 from_var: "qa_score".to_string(),
535 };
536 let json = serde_json::to_string(&action).expect("serialize StorePut");
537 assert!(json.contains("\"type\":\"store_put\""));
538 assert!(json.contains("\"store\":\"metrics\""));
539 assert!(json.contains("\"key\":\"bench_result\""));
540 assert!(json.contains("\"from_var\":\"qa_score\""));
541
542 let deserialized: PostAction = serde_json::from_str(&json).expect("deserialize StorePut");
543 match deserialized {
544 PostAction::StorePut {
545 store,
546 key,
547 from_var,
548 } => {
549 assert_eq!(store, "metrics");
550 assert_eq!(key, "bench_result");
551 assert_eq!(from_var, "qa_score");
552 }
553 _ => panic!("expected StorePut variant"),
554 }
555 }
556}