1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4use std::time::Instant;
5
6use super::{
7 AgentConfig, CONVENTIONS, CostPreference, ExecutionMode, ExecutionProfileConfig,
8 InvariantConfig, ItemIsolationConfig, ItemSelectConfig, OrchestratorConfig, PipelineVariables,
9 SafetyConfig, StepBehavior, StepPrehookConfig, StepScope, StoreInputConfig, StoreOutputConfig,
10 WorkflowConfig, WorkflowExecutionConfig, WorkflowFinalizeConfig, WorkflowLoopConfig,
11 is_known_builtin_step_name,
12};
13
14fn default_true() -> bool {
15 true
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TaskExecutionStep {
21 pub id: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub required_capability: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub template: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub execution_profile: Option<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub builtin: Option<String>,
35 #[serde(default = "default_true")]
37 pub enabled: bool,
38 #[serde(default = "default_true")]
40 pub repeatable: bool,
41 #[serde(default)]
43 pub is_guard: bool,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cost_preference: Option<CostPreference>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub prehook: Option<StepPrehookConfig>,
50 #[serde(default)]
52 pub tty: bool,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub outputs: Vec<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub pipe_to: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub command: Option<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub chain_steps: Vec<TaskExecutionStep>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub scope: Option<StepScope>,
68 #[serde(default)]
70 pub behavior: StepBehavior,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub max_parallel: Option<usize>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub stagger_delay_ms: Option<u64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub timeout_secs: Option<u64>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub stall_timeout_secs: Option<u64>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub item_select_config: Option<ItemSelectConfig>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub store_inputs: Vec<StoreInputConfig>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub store_outputs: Vec<StoreOutputConfig>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub step_vars: Option<std::collections::HashMap<String, String>>,
96}
97
98impl TaskExecutionStep {
99 pub fn resolved_scope(&self) -> StepScope {
102 self.scope.unwrap_or_else(|| {
103 let scope = CONVENTIONS.default_scope(&self.id);
104 if scope == StepScope::Task {
105 if let Some(ref cap) = self.required_capability {
106 let cap_scope = CONVENTIONS.default_scope(cap);
107 if cap_scope == StepScope::Item {
108 return cap_scope;
109 }
110 }
111 }
112 scope
113 })
114 }
115
116 pub fn effective_execution_mode(&self) -> std::borrow::Cow<'_, ExecutionMode> {
131 if !self.chain_steps.is_empty() {
132 return std::borrow::Cow::Owned(ExecutionMode::Chain);
133 }
134 if let Some(ref bname) = self.builtin {
135 if is_known_builtin_step_name(bname) {
136 return std::borrow::Cow::Owned(ExecutionMode::Builtin {
137 name: bname.clone(),
138 });
139 }
140 }
141 if self.command.is_some() {
142 return std::borrow::Cow::Owned(ExecutionMode::Builtin {
143 name: self.id.clone(),
144 });
145 }
146 std::borrow::Cow::Borrowed(&self.behavior.execution)
147 }
148
149 pub fn renormalize_execution_mode(&mut self) {
163 for chain_step in &mut self.chain_steps {
164 chain_step.renormalize_execution_mode();
165 }
166
167 if !self.chain_steps.is_empty() {
168 self.behavior.execution = ExecutionMode::Chain;
169 return;
170 }
171
172 if let Some(ref name) = self.builtin.clone() {
173 if is_known_builtin_step_name(name) {
174 self.behavior.execution = ExecutionMode::Builtin { name: name.clone() };
175 self.required_capability = None;
176 return;
177 }
178 }
179
180 if self.command.is_some() {
181 self.behavior.execution = ExecutionMode::Builtin {
182 name: self.id.clone(),
183 };
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TaskExecutionPlan {
191 pub steps: Vec<TaskExecutionStep>,
193 #[serde(rename = "loop")]
194 pub loop_policy: WorkflowLoopConfig,
196 #[serde(default)]
198 pub finalize: WorkflowFinalizeConfig,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub max_parallel: Option<usize>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub stagger_delay_ms: Option<u64>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub item_isolation: Option<ItemIsolationConfig>,
208}
209
210impl TaskExecutionPlan {
211 pub fn step_by_id(&self, id: &str) -> Option<&TaskExecutionStep> {
213 self.steps.iter().find(|step| step.id == id)
214 }
215}
216
217#[derive(Debug, Clone)]
219pub struct TaskRuntimeContext {
220 pub workspace_id: String,
222 pub workspace_root: std::path::PathBuf,
224 pub ticket_dir: String,
226 pub execution_plan: Arc<TaskExecutionPlan>,
228 pub execution: WorkflowExecutionConfig,
230 pub current_cycle: u32,
232 pub init_done: bool,
234 pub dynamic_steps: Arc<Vec<crate::dynamic_step::DynamicStepConfig>>,
236 pub adaptive: Arc<Option<crate::adaptive::AdaptivePlannerConfig>>,
238 pub pipeline_vars: PipelineVariables,
240 pub safety: Arc<SafetyConfig>,
242 pub self_referential: bool,
244 pub consecutive_failures: u32,
246 pub project_id: String,
248 pub pinned_invariants: Arc<Vec<InvariantConfig>>,
250 pub workflow_id: String,
252 pub spawn_depth: i64,
254 pub item_step_failures: HashMap<(String, String), u32>,
256 pub item_retry_after: HashMap<String, Instant>,
258 pub restart_completed_steps: HashSet<String>,
261}
262
263impl TaskRuntimeContext {
264 pub fn adaptive_config(&self) -> Option<&crate::adaptive::AdaptivePlannerConfig> {
266 self.adaptive.as_ref().as_ref()
267 }
268
269 pub fn dynamic_step_configs(&self) -> &[crate::dynamic_step::DynamicStepConfig] {
271 self.dynamic_steps.as_ref().as_slice()
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct StepPrehookContext {
278 pub task_id: String,
280 pub task_item_id: String,
282 pub cycle: u32,
284 pub step: String,
286 pub qa_file_path: String,
288 pub item_status: String,
290 pub task_status: String,
292 pub qa_exit_code: Option<i64>,
294 pub fix_exit_code: Option<i64>,
296 pub retest_exit_code: Option<i64>,
298 pub active_ticket_count: i64,
300 pub new_ticket_count: i64,
302 pub qa_failed: bool,
304 pub fix_required: bool,
306 pub qa_confidence: Option<f32>,
308 pub qa_quality_score: Option<f32>,
310 pub fix_has_changes: Option<bool>,
312 #[serde(default)]
314 pub upstream_artifacts: Vec<ArtifactSummary>,
315 #[serde(default)]
317 pub build_error_count: i64,
318 #[serde(default)]
320 pub test_failure_count: i64,
321 pub build_exit_code: Option<i64>,
323 pub test_exit_code: Option<i64>,
325 #[serde(default)]
327 pub self_test_exit_code: Option<i64>,
328 #[serde(default)]
330 pub self_test_passed: bool,
331 #[serde(default)]
333 pub max_cycles: u32,
334 #[serde(default)]
336 pub is_last_cycle: bool,
337 #[serde(default)]
339 pub last_sandbox_denied: bool,
340 #[serde(default)]
342 pub sandbox_denied_count: u32,
343 #[serde(default)]
345 pub last_sandbox_denial_reason: Option<String>,
346 #[serde(default = "default_true")]
348 pub self_referential_safe: bool,
349 #[serde(default)]
352 pub self_referential_safe_scenarios: Vec<String>,
353 #[serde(default)]
356 pub vars: std::collections::HashMap<String, String>,
357}
358
359#[derive(Debug, Clone, Default)]
361pub struct ConvergenceContext {
362 pub cycle: u32,
364 pub active_ticket_count: i64,
366 pub self_test_passed: bool,
368 pub max_cycles: u32,
370 pub vars: std::collections::HashMap<String, String>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, Default)]
376pub struct ArtifactSummary {
377 pub phase: String,
379 pub kind: String,
381 pub path: Option<String>,
383}
384
385#[derive(Debug, Clone, Serialize)]
387pub struct ItemFinalizeContext {
388 pub task_id: String,
390 pub task_item_id: String,
392 pub cycle: u32,
394 pub qa_file_path: String,
396 pub item_status: String,
398 pub task_status: String,
400 pub qa_exit_code: Option<i64>,
402 pub fix_exit_code: Option<i64>,
404 pub retest_exit_code: Option<i64>,
406 pub active_ticket_count: i64,
408 pub new_ticket_count: i64,
410 pub retest_new_ticket_count: i64,
412 pub qa_failed: bool,
414 pub fix_required: bool,
416 pub qa_configured: bool,
418 pub qa_observed: bool,
420 pub qa_enabled: bool,
422 pub qa_ran: bool,
424 pub qa_skipped: bool,
426 pub fix_configured: bool,
428 pub fix_enabled: bool,
430 pub fix_ran: bool,
432 pub fix_skipped: bool,
434 pub fix_success: bool,
436 pub retest_enabled: bool,
438 pub retest_ran: bool,
440 pub retest_success: bool,
442 pub qa_confidence: Option<f32>,
444 pub qa_quality_score: Option<f32>,
446 pub fix_confidence: Option<f32>,
448 pub fix_quality_score: Option<f32>,
450 pub total_artifacts: i64,
452 pub has_ticket_artifacts: bool,
454 pub has_code_change_artifacts: bool,
456 pub is_last_cycle: bool,
458 pub last_sandbox_denied: bool,
460 pub sandbox_denied_count: u32,
462 pub last_sandbox_denial_reason: Option<String>,
464}
465
466#[derive(Debug, Clone)]
468pub struct WorkflowFinalizeOutcome {
469 pub rule_id: String,
471 pub status: String,
473 pub reason: String,
475}
476
477#[derive(Debug, Clone)]
479pub struct ResolvedWorkspace {
480 pub root_path: std::path::PathBuf,
482 pub qa_targets: Vec<String>,
484 pub ticket_dir: String,
486}
487
488#[derive(Debug, Clone)]
490pub struct ResolvedProject {
491 pub workspaces: HashMap<String, ResolvedWorkspace>,
493 pub agents: HashMap<String, AgentConfig>,
495 pub workflows: HashMap<String, WorkflowConfig>,
497 pub step_templates: HashMap<String, crate::config::StepTemplateConfig>,
499 pub env_stores: HashMap<String, crate::config::EnvStoreConfig>,
501 pub secret_stores: HashMap<String, crate::config::SecretStoreConfig>,
503 pub execution_profiles: HashMap<String, ExecutionProfileConfig>,
505}
506
507#[derive(Debug, Clone)]
509pub struct ActiveConfig {
510 pub config: OrchestratorConfig,
512 pub workspaces: HashMap<String, ResolvedWorkspace>,
514 pub projects: HashMap<String, ResolvedProject>,
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn make_agent_step(
523 id: &str,
524 builtin: Option<&str>,
525 capability: Option<&str>,
526 ) -> TaskExecutionStep {
527 TaskExecutionStep {
528 id: id.to_string(),
529 required_capability: capability.map(|s| s.to_string()),
530 template: None,
531 execution_profile: None,
532 builtin: builtin.map(|s| s.to_string()),
533 enabled: true,
534 repeatable: true,
535 is_guard: false,
536 cost_preference: None,
537 prehook: None,
538 tty: false,
539 outputs: vec![],
540 pipe_to: None,
541 command: None,
542 chain_steps: vec![],
543 scope: None,
544 behavior: StepBehavior::default(),
545 max_parallel: None,
546 stagger_delay_ms: None,
547 timeout_secs: None,
548 stall_timeout_secs: None,
549 item_select_config: None,
550 store_inputs: vec![],
551 store_outputs: vec![],
552 step_vars: None,
553 }
554 }
555
556 #[test]
557 fn test_resolved_scope_explicit_override() {
558 let step = TaskExecutionStep {
559 id: "qa".to_string(), required_capability: None,
561 template: None,
562 execution_profile: None,
563 builtin: None,
564 enabled: true,
565 repeatable: true,
566 is_guard: false,
567 cost_preference: None,
568 prehook: None,
569 tty: false,
570 outputs: vec![],
571 pipe_to: None,
572 command: None,
573 chain_steps: vec![],
574 scope: Some(StepScope::Task), behavior: StepBehavior::default(),
576 max_parallel: None,
577 stagger_delay_ms: None,
578 timeout_secs: None,
579 stall_timeout_secs: None,
580 item_select_config: None,
581 store_inputs: vec![],
582 store_outputs: vec![],
583 step_vars: None,
584 };
585 assert_eq!(step.resolved_scope(), StepScope::Task);
586 }
587
588 #[test]
589 fn test_resolved_scope_from_step_id() {
590 let step = TaskExecutionStep {
591 id: "plan".to_string(),
592 required_capability: None,
593 template: None,
594 execution_profile: None,
595 builtin: None,
596 enabled: true,
597 repeatable: true,
598 is_guard: false,
599 cost_preference: None,
600 prehook: None,
601 tty: false,
602 outputs: vec![],
603 pipe_to: None,
604 command: None,
605 chain_steps: vec![],
606 scope: None,
607 behavior: StepBehavior::default(),
608 max_parallel: None,
609 stagger_delay_ms: None,
610 timeout_secs: None,
611 stall_timeout_secs: None,
612 item_select_config: None,
613 store_inputs: vec![],
614 store_outputs: vec![],
615 step_vars: None,
616 };
617 assert_eq!(step.resolved_scope(), StepScope::Task);
618 }
619
620 #[test]
621 fn test_resolved_scope_unknown_id_defaults_to_task() {
622 let step = TaskExecutionStep {
623 id: "my_custom_step".to_string(),
624 required_capability: None,
625 template: None,
626 execution_profile: None,
627 builtin: None,
628 enabled: true,
629 repeatable: true,
630 is_guard: false,
631 cost_preference: None,
632 prehook: None,
633 tty: false,
634 outputs: vec![],
635 pipe_to: None,
636 command: None,
637 chain_steps: vec![],
638 scope: None,
639 behavior: StepBehavior::default(),
640 max_parallel: None,
641 stagger_delay_ms: None,
642 timeout_secs: None,
643 stall_timeout_secs: None,
644 item_select_config: None,
645 store_inputs: vec![],
646 store_outputs: vec![],
647 step_vars: None,
648 };
649 assert_eq!(step.resolved_scope(), StepScope::Task);
650 }
651
652 #[test]
653 fn test_task_execution_plan_step_by_id_found() {
654 let plan = TaskExecutionPlan {
655 steps: vec![
656 TaskExecutionStep {
657 id: "plan".to_string(),
658 required_capability: None,
659 template: None,
660 execution_profile: None,
661 builtin: None,
662 enabled: true,
663 repeatable: false,
664 is_guard: false,
665 cost_preference: None,
666 prehook: None,
667 tty: false,
668 outputs: vec![],
669 pipe_to: None,
670 command: None,
671 chain_steps: vec![],
672 scope: None,
673 behavior: StepBehavior::default(),
674 max_parallel: None,
675 stagger_delay_ms: None,
676 timeout_secs: None,
677 stall_timeout_secs: None,
678 item_select_config: None,
679 store_inputs: vec![],
680 store_outputs: vec![],
681 step_vars: None,
682 },
683 TaskExecutionStep {
684 id: "qa".to_string(),
685 required_capability: None,
686 template: None,
687 execution_profile: None,
688 builtin: None,
689 enabled: true,
690 repeatable: true,
691 is_guard: false,
692 cost_preference: None,
693 prehook: None,
694 tty: false,
695 outputs: vec![],
696 pipe_to: None,
697 command: None,
698 chain_steps: vec![],
699 scope: None,
700 behavior: StepBehavior::default(),
701 max_parallel: None,
702 stagger_delay_ms: None,
703 timeout_secs: None,
704 stall_timeout_secs: None,
705 item_select_config: None,
706 store_inputs: vec![],
707 store_outputs: vec![],
708 step_vars: None,
709 },
710 ],
711 loop_policy: WorkflowLoopConfig::default(),
712 finalize: WorkflowFinalizeConfig::default(),
713 max_parallel: None,
714 stagger_delay_ms: None,
715 item_isolation: None,
716 };
717
718 let found = plan.step_by_id("qa");
719 let found = found.expect("qa step should be found");
720 assert_eq!(found.id, "qa");
721
722 let found_plan = plan.step_by_id("plan");
723 let found_plan = found_plan.expect("plan step should be found");
724 assert_eq!(found_plan.id, "plan");
725 }
726
727 #[test]
728 fn test_task_execution_plan_step_by_id_not_found() {
729 let plan = TaskExecutionPlan {
730 steps: vec![],
731 loop_policy: WorkflowLoopConfig::default(),
732 finalize: WorkflowFinalizeConfig::default(),
733 max_parallel: None,
734 stagger_delay_ms: None,
735 item_isolation: None,
736 };
737 assert!(plan.step_by_id("fix").is_none());
738 }
739
740 #[test]
741 fn renormalize_corrects_stale_agent_to_builtin() {
742 let mut step = make_agent_step("self_test", Some("self_test"), None);
743 assert_eq!(step.behavior.execution, ExecutionMode::Agent);
745 step.renormalize_execution_mode();
746 assert_eq!(
747 step.behavior.execution,
748 ExecutionMode::Builtin {
749 name: "self_test".to_string()
750 }
751 );
752 }
753
754 #[test]
755 fn renormalize_clears_stale_required_capability() {
756 let mut step = make_agent_step("self_test", Some("self_test"), Some("self_test"));
757 step.renormalize_execution_mode();
758 assert!(step.required_capability.is_none());
759 }
760
761 #[test]
762 fn renormalize_noop_for_correct_builtin() {
763 let mut step = make_agent_step("self_test", Some("self_test"), None);
764 step.behavior.execution = ExecutionMode::Builtin {
765 name: "self_test".to_string(),
766 };
767 step.renormalize_execution_mode();
768 assert_eq!(
769 step.behavior.execution,
770 ExecutionMode::Builtin {
771 name: "self_test".to_string()
772 }
773 );
774 }
775
776 #[test]
777 fn renormalize_noop_for_agent_step() {
778 let mut step = make_agent_step("plan", None, Some("plan"));
779 step.renormalize_execution_mode();
780 assert_eq!(step.behavior.execution, ExecutionMode::Agent);
782 assert_eq!(step.required_capability, Some("plan".to_string()));
783 }
784
785 #[test]
786 fn renormalize_restores_chain_execution_recursively() {
787 let mut step = make_agent_step("smoke_chain", None, Some("smoke_chain"));
788 step.chain_steps = vec![TaskExecutionStep {
789 id: "chain_plan".to_string(),
790 command: Some("printf 'CHAIN_PLAN'".to_string()),
791 ..make_agent_step("chain_plan", None, None)
792 }];
793
794 step.renormalize_execution_mode();
795
796 assert_eq!(step.behavior.execution, ExecutionMode::Chain);
797 assert_eq!(
798 step.chain_steps[0].behavior.execution,
799 ExecutionMode::Builtin {
800 name: "chain_plan".to_string()
801 }
802 );
803 }
804
805 #[test]
806 fn renormalize_handles_all_known_builtins() {
807 for name in &["init_once", "loop_guard", "ticket_scan", "self_test"] {
808 let mut step = make_agent_step(name, Some(name), None);
809 assert_eq!(
811 step.behavior.execution,
812 ExecutionMode::Agent,
813 "name={}",
814 name
815 );
816 step.renormalize_execution_mode();
817 assert_eq!(
818 step.behavior.execution,
819 ExecutionMode::Builtin {
820 name: name.to_string()
821 },
822 "name={}",
823 name
824 );
825 }
826 }
827
828 #[test]
829 fn step_prehook_context_serde_defaults_round_trip() {
830 let json = serde_json::json!({
831 "task_id": "task-1",
832 "task_item_id": "item-1",
833 "cycle": 1,
834 "step": "qa_testing",
835 "qa_file_path": "docs/qa/test.md",
836 "item_status": "pending",
837 "task_status": "running",
838 "qa_exit_code": 1,
839 "fix_exit_code": null,
840 "retest_exit_code": null,
841 "active_ticket_count": 2,
842 "new_ticket_count": 1,
843 "qa_failed": true,
844 "fix_required": true,
845 "qa_confidence": 0.9,
846 "qa_quality_score": 0.7,
847 "fix_has_changes": null
848 });
849
850 let context: StepPrehookContext =
851 serde_json::from_value(json).expect("context should deserialize");
852 assert!(context.upstream_artifacts.is_empty());
853 assert_eq!(context.build_error_count, 0);
854 assert_eq!(context.test_failure_count, 0);
855 assert_eq!(context.self_test_exit_code, None);
856 assert!(!context.self_test_passed);
857 assert_eq!(context.max_cycles, 0);
858 assert!(!context.is_last_cycle);
859 assert!(context.self_referential_safe);
860
861 let artifact = ArtifactSummary {
862 phase: "qa".to_string(),
863 kind: "report".to_string(),
864 path: Some("artifacts/report.json".to_string()),
865 };
866 let round_trip = StepPrehookContext {
867 upstream_artifacts: vec![artifact],
868 build_error_count: 3,
869 test_failure_count: 4,
870 self_test_exit_code: Some(2),
871 self_test_passed: true,
872 max_cycles: 5,
873 is_last_cycle: false,
874 self_referential_safe: false,
875 ..context
876 };
877 let serialized = serde_json::to_value(&round_trip).expect("context should serialize");
878 let reparsed: StepPrehookContext =
879 serde_json::from_value(serialized).expect("context should round-trip");
880 assert_eq!(reparsed.upstream_artifacts.len(), 1);
881 assert_eq!(reparsed.build_error_count, 3);
882 assert_eq!(reparsed.test_failure_count, 4);
883 assert_eq!(reparsed.self_test_exit_code, Some(2));
884 assert!(reparsed.self_test_passed);
885 assert_eq!(reparsed.max_cycles, 5);
886 assert!(!reparsed.is_last_cycle);
887 assert!(!reparsed.self_referential_safe);
888 }
889}