Skip to main content

codewhale_workflow/
lib.rs

1//! Typed Workflow IR and validation for CodeWhale.
2//!
3//! This crate deliberately stops at the Rust-owned IR boundary. Runtime tool
4//! exposure, worktree application, replay, and model execution are layered on
5//! top only after their cancellation and evidence semantics are proven.
6
7mod js_authoring;
8mod model_policy;
9mod replay;
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub use js_authoring::{
18    JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow,
19    compile_typescript_workflow,
20};
21pub use model_policy::*;
22pub use replay::*;
23
24pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 100;
25pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 5;
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct WorkflowConfig {
29    pub goal: String,
30    #[serde(default = "default_max_concurrent")]
31    pub max_concurrent: u8,
32    #[serde(default)]
33    pub description: Option<String>,
34    #[serde(default)]
35    pub phases: Vec<Phase>,
36}
37
38impl WorkflowConfig {
39    pub fn validate(&self) -> Result<(), WorkflowValidationError> {
40        WorkflowPlan::from_config(self).map(|_| ())
41    }
42
43    pub fn compile(&self) -> Result<WorkflowPlan, WorkflowValidationError> {
44        WorkflowPlan::from_config(self)
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct WorkflowSpec {
50    #[serde(default)]
51    pub id: Option<String>,
52    pub goal: String,
53    #[serde(default)]
54    pub description: Option<String>,
55    #[serde(default)]
56    pub budget: BudgetSpec,
57    #[serde(default)]
58    pub permissions: PermissionSpec,
59    #[serde(default)]
60    pub model_policy: ModelPolicy,
61    #[serde(default)]
62    pub promotion_policy: PromotionPolicy,
63    #[serde(default)]
64    pub nodes: Vec<WorkflowNode>,
65}
66
67impl WorkflowSpec {
68    pub fn validate_for_fleet(&self) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
69        self.validate_for_fleet_with_limits(WorkflowFleetLimits::default())
70    }
71
72    pub fn validate_for_fleet_with_limits(
73        &self,
74        limits: WorkflowFleetLimits,
75    ) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
76        validate_workflow_nodes(&self.nodes)
77            .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
78        let shape = estimate_fleet_shape(&self.nodes)?;
79        if shape.total_agents > limits.max_total_agents {
80            return Err(WorkflowFleetLimitError::TooManyAgents {
81                total_agents: shape.total_agents,
82                max_total_agents: limits.max_total_agents,
83            });
84        }
85        if shape.max_depth > limits.max_depth {
86            return Err(WorkflowFleetLimitError::RecursionTooDeep {
87                depth: shape.max_depth,
88                max_depth: limits.max_depth,
89            });
90        }
91        Ok(shape)
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(tag = "kind", content = "spec", rename_all = "snake_case")]
97pub enum WorkflowNode {
98    BranchSet(BranchSpec),
99    Leaf(LeafSpec),
100    Sequence(SequenceSpec),
101    Reduce(ReduceSpec),
102    TeacherReview(TeacherReviewSpec),
103    LoopUntil(LoopUntilSpec),
104    Cond(CondSpec),
105    Expand(ExpandSpec),
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BranchSpec {
110    pub id: String,
111    #[serde(default)]
112    pub description: Option<String>,
113    #[serde(default)]
114    pub parallel: bool,
115    #[serde(default)]
116    pub budget: BudgetSpec,
117    #[serde(default)]
118    pub permissions: PermissionSpec,
119    #[serde(default)]
120    pub model_policy: ModelPolicy,
121    #[serde(default)]
122    pub children: Vec<WorkflowNode>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct LeafSpec {
127    pub id: String,
128    pub prompt: String,
129    #[serde(default)]
130    pub agent_type: AgentType,
131    /// Named Fleet roster profile this agent should run as. Resolved against
132    /// the saved Fleet roster at dispatch time; unknown names fail validation
133    /// before any spawn. When set, role/model/loadout defaults come from the
134    /// roster member; explicit fields on this spec override the profile.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub profile: Option<String>,
137    #[serde(default)]
138    pub mode: TaskMode,
139    #[serde(default)]
140    pub isolation: IsolationMode,
141    #[serde(default)]
142    pub file_scope: Vec<String>,
143    #[serde(default)]
144    pub depends_on_results: Vec<String>,
145    #[serde(default)]
146    pub budget: BudgetSpec,
147    #[serde(default)]
148    pub permissions: PermissionSpec,
149    #[serde(default)]
150    pub model_policy: ModelPolicy,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct SequenceSpec {
155    pub id: String,
156    #[serde(default)]
157    pub children: Vec<WorkflowNode>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ReduceSpec {
162    pub id: String,
163    #[serde(default)]
164    pub inputs: Vec<String>,
165    pub prompt: String,
166    #[serde(default)]
167    pub model_policy: ModelPolicy,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct TeacherReviewSpec {
172    pub id: String,
173    #[serde(default)]
174    pub candidates: Vec<String>,
175    #[serde(default)]
176    pub promotion_policy: PromotionPolicy,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct LoopUntilSpec {
181    pub id: String,
182    pub condition: String,
183    #[serde(default)]
184    pub max_iterations: Option<u32>,
185    #[serde(default)]
186    pub children: Vec<WorkflowNode>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct CondSpec {
191    pub id: String,
192    pub condition: String,
193    #[serde(default)]
194    pub then_nodes: Vec<WorkflowNode>,
195    #[serde(default)]
196    pub else_nodes: Vec<WorkflowNode>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ExpandSpec {
201    pub id: String,
202    pub source: String,
203    #[serde(default)]
204    pub max_children: Option<usize>,
205    #[serde(default)]
206    pub template: Option<Box<WorkflowNode>>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
210pub struct BudgetSpec {
211    #[serde(default)]
212    pub max_steps: Option<u32>,
213    #[serde(default)]
214    pub timeout_secs: Option<u64>,
215    #[serde(default)]
216    pub max_parallel: Option<u8>,
217    #[serde(default)]
218    pub max_tokens: Option<u64>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
222pub struct PermissionSpec {
223    #[serde(default)]
224    pub allow_write: bool,
225    #[serde(default)]
226    pub allow_network: bool,
227    #[serde(default)]
228    pub allowed_tools: Vec<String>,
229    #[serde(default)]
230    pub file_scope: Vec<String>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
234pub struct ModelPolicy {
235    #[serde(default)]
236    pub provider: Option<String>,
237    #[serde(default)]
238    pub model: Option<String>,
239    #[serde(default)]
240    pub fallback_models: Vec<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
244pub struct PromotionPolicy {
245    #[serde(default)]
246    pub strategy: PromotionStrategy,
247    #[serde(default)]
248    pub require_teacher_review: bool,
249    #[serde(default)]
250    pub min_successful_branches: Option<u32>,
251    #[serde(default)]
252    pub promotion_gate: PromotionGate,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub enum PromotionStrategy {
258    #[default]
259    All,
260    FirstSuccess,
261    BestScore,
262    TeacherSelected,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct WorkflowPlan {
267    goal: String,
268    max_concurrent: u8,
269    phases: Vec<PhasePlan>,
270}
271
272impl WorkflowPlan {
273    pub fn from_config(config: &WorkflowConfig) -> Result<Self, WorkflowValidationError> {
274        validate_non_empty("workflow goal", &config.goal)?;
275        if !(1..=20).contains(&config.max_concurrent) {
276            return Err(WorkflowValidationError::InvalidMaxConcurrent {
277                value: config.max_concurrent,
278            });
279        }
280        if config.phases.is_empty() {
281            return Err(WorkflowValidationError::EmptyWorkflow);
282        }
283
284        let mut phase_indices = BTreeMap::new();
285        let mut all_tasks = BTreeMap::new();
286        let mut task_phase = BTreeMap::new();
287
288        for (phase_index, phase) in config.phases.iter().enumerate() {
289            validate_non_empty("phase name", &phase.name)?;
290            if phase.tasks.is_empty() {
291                return Err(WorkflowValidationError::EmptyPhase {
292                    phase: phase.name.clone(),
293                });
294            }
295            if phase_indices
296                .insert(phase.name.clone(), phase_index)
297                .is_some()
298            {
299                return Err(WorkflowValidationError::DuplicatePhase {
300                    phase: phase.name.clone(),
301                });
302            }
303
304            for task in &phase.tasks {
305                validate_non_empty("task id", &task.id)?;
306                validate_non_empty("task prompt", &task.prompt)?;
307                if all_tasks.insert(task.id.clone(), task).is_some() {
308                    return Err(WorkflowValidationError::DuplicateTask {
309                        task: task.id.clone(),
310                    });
311                }
312                task_phase.insert(task.id.clone(), phase.name.clone());
313            }
314        }
315
316        for phase in &config.phases {
317            for dependency in &phase.depends_on {
318                if dependency == &phase.name || !phase_indices.contains_key(dependency) {
319                    return Err(WorkflowValidationError::InvalidPhaseDependency {
320                        phase: phase.name.clone(),
321                        dependency: dependency.clone(),
322                    });
323                }
324            }
325            validate_parallel_write_scope(phase)?;
326        }
327
328        let ordered_phase_names = ordered_phases(config, &phase_indices)?;
329        let phase_order: BTreeMap<_, _> = ordered_phase_names
330            .iter()
331            .enumerate()
332            .map(|(index, phase)| (phase.clone(), index))
333            .collect();
334
335        for phase in &config.phases {
336            for task in &phase.tasks {
337                for dependency in &task.depends_on_results {
338                    let Some(dependency_phase) = task_phase.get(dependency) else {
339                        return Err(WorkflowValidationError::InvalidTaskResultDependency {
340                            task: task.id.clone(),
341                            dependency: dependency.clone(),
342                        });
343                    };
344                    if phase_order[dependency_phase] >= phase_order[&phase.name] {
345                        return Err(WorkflowValidationError::UnavailableTaskResultDependency {
346                            task: task.id.clone(),
347                            dependency: dependency.clone(),
348                            dependency_phase: dependency_phase.clone(),
349                            task_phase: phase.name.clone(),
350                        });
351                    }
352                }
353            }
354        }
355
356        let phases = ordered_phase_names
357            .iter()
358            .map(|phase_name| {
359                let phase = &config.phases[phase_indices[phase_name]];
360                PhasePlan {
361                    name: phase.name.clone(),
362                    parallel: phase.parallel,
363                    on_failure: phase.on_failure,
364                    tasks: phase.tasks.clone(),
365                }
366            })
367            .collect();
368
369        Ok(Self {
370            goal: config.goal.clone(),
371            max_concurrent: config.max_concurrent,
372            phases,
373        })
374    }
375
376    pub fn goal(&self) -> &str {
377        &self.goal
378    }
379
380    pub fn max_concurrent(&self) -> u8 {
381        self.max_concurrent
382    }
383
384    pub fn phases(&self) -> &[PhasePlan] {
385        &self.phases
386    }
387
388    pub fn phase_names(&self) -> impl Iterator<Item = &str> {
389        self.phases.iter().map(|phase| phase.name.as_str())
390    }
391}
392
393pub type WorkflowIr = WorkflowPlan;
394
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct PhasePlan {
397    pub name: String,
398    pub parallel: bool,
399    pub on_failure: FailurePolicy,
400    pub tasks: Vec<Task>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct Phase {
405    pub name: String,
406    #[serde(default)]
407    pub description: Option<String>,
408    #[serde(default)]
409    pub depends_on: Vec<String>,
410    #[serde(default)]
411    pub parallel: bool,
412    #[serde(default)]
413    pub on_failure: FailurePolicy,
414    #[serde(default)]
415    pub tasks: Vec<Task>,
416}
417
418pub type WorkflowPhase = Phase;
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
421#[serde(rename_all = "snake_case")]
422pub enum FailurePolicy {
423    #[default]
424    SkipContinue,
425    Abort,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct Task {
430    pub id: String,
431    pub prompt: String,
432    #[serde(default)]
433    pub agent_type: AgentType,
434    #[serde(default)]
435    pub mode: TaskMode,
436    #[serde(default)]
437    pub isolation: IsolationMode,
438    #[serde(default)]
439    pub file_scope: Vec<String>,
440    #[serde(default)]
441    pub depends_on_results: Vec<String>,
442    #[serde(default)]
443    pub max_steps: Option<u32>,
444    #[serde(default)]
445    pub timeout_secs: Option<u64>,
446}
447
448pub type WorkflowTask = Task;
449pub type WorkflowRole = AgentType;
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
452#[serde(rename_all = "snake_case")]
453pub enum AgentType {
454    #[default]
455    General,
456    Explore,
457    Plan,
458    Review,
459    Implementer,
460    Verifier,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
464#[serde(rename_all = "snake_case")]
465pub enum TaskMode {
466    #[default]
467    ReadOnly,
468    ReadWrite,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
472#[serde(rename_all = "snake_case")]
473pub enum IsolationMode {
474    #[default]
475    Shared,
476    Worktree,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct BranchResult {
481    pub branch_id: String,
482    pub task_id: String,
483    pub status: WorkflowRunStatus,
484    #[serde(default)]
485    pub usage: WorkflowUsage,
486    #[serde(default)]
487    pub memo_usage: WorkflowMemoUsage,
488    #[serde(default)]
489    pub artifacts: Vec<String>,
490    #[serde(default)]
491    pub notes: Option<String>,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
495pub struct LeafResult {
496    pub leaf_id: String,
497    pub task_id: String,
498    /// Fleet roster profile the leaf was declared to run as, if any.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub profile: Option<String>,
501    pub status: WorkflowRunStatus,
502    #[serde(default)]
503    pub usage: WorkflowUsage,
504    #[serde(default)]
505    pub memo_usage: WorkflowMemoUsage,
506    #[serde(default)]
507    pub output: Option<String>,
508    #[serde(default)]
509    pub artifacts: Vec<String>,
510    /// Post-hoc validation failure for the leaf's structured response.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub schema_error: Option<String>,
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
516pub struct WorkflowUsage {
517    #[serde(default)]
518    pub input_tokens: u64,
519    #[serde(default)]
520    pub output_tokens: u64,
521    #[serde(default)]
522    pub cost_microusd: u64,
523}
524
525impl WorkflowUsage {
526    #[must_use]
527    pub fn total_tokens(self) -> u64 {
528        self.input_tokens.saturating_add(self.output_tokens)
529    }
530
531    pub(crate) fn add_assign(&mut self, other: Self) {
532        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
533        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
534        self.cost_microusd = self.cost_microusd.saturating_add(other.cost_microusd);
535    }
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
539pub struct WorkflowMemoUsage {
540    #[serde(default)]
541    pub armh_hits: u64,
542    #[serde(default)]
543    pub armh_misses: u64,
544    #[serde(default)]
545    pub armh_saved_estimated_tokens: u64,
546    #[serde(default)]
547    pub provider_prompt_cache_hits: u64,
548    #[serde(default)]
549    pub provider_prompt_cache_misses: u64,
550}
551
552impl WorkflowMemoUsage {
553    pub(crate) fn add_assign(&mut self, other: Self) {
554        self.armh_hits = self.armh_hits.saturating_add(other.armh_hits);
555        self.armh_misses = self.armh_misses.saturating_add(other.armh_misses);
556        self.armh_saved_estimated_tokens = self
557            .armh_saved_estimated_tokens
558            .saturating_add(other.armh_saved_estimated_tokens);
559        self.provider_prompt_cache_hits = self
560            .provider_prompt_cache_hits
561            .saturating_add(other.provider_prompt_cache_hits);
562        self.provider_prompt_cache_misses = self
563            .provider_prompt_cache_misses
564            .saturating_add(other.provider_prompt_cache_misses);
565    }
566}
567
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct ControlNodeResult {
570    pub node_id: String,
571    pub kind: ControlNodeKind,
572    pub status: WorkflowRunStatus,
573    #[serde(default)]
574    pub selected_children: Vec<String>,
575    #[serde(default)]
576    pub summary: Option<String>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
580#[serde(rename_all = "snake_case")]
581pub enum WorkflowRunStatus {
582    #[default]
583    Pending,
584    Running,
585    Succeeded,
586    Failed,
587    Cancelled,
588    BudgetExceeded,
589    ReplayDiverged,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
593#[serde(rename_all = "snake_case")]
594pub enum ControlNodeKind {
595    BranchSet,
596    Leaf,
597    Sequence,
598    Reduce,
599    TeacherReview,
600    LoopUntil,
601    Cond,
602    Expand,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct WorkflowExecution {
607    pub status: WorkflowRunStatus,
608    #[serde(default)]
609    pub usage: WorkflowUsage,
610    #[serde(default)]
611    pub memo_usage: WorkflowMemoUsage,
612    #[serde(default)]
613    pub leaf_results: Vec<LeafResult>,
614    #[serde(default)]
615    pub branch_results: Vec<BranchResult>,
616    #[serde(default)]
617    pub control_node_results: Vec<ControlNodeResult>,
618}
619
620impl Default for WorkflowExecution {
621    fn default() -> Self {
622        Self {
623            status: WorkflowRunStatus::Succeeded,
624            usage: WorkflowUsage::default(),
625            memo_usage: WorkflowMemoUsage::default(),
626            leaf_results: Vec::new(),
627            branch_results: Vec::new(),
628            control_node_results: Vec::new(),
629        }
630    }
631}
632
633impl WorkflowExecution {
634    pub fn mark_failed(&mut self) {
635        self.status = WorkflowRunStatus::Failed;
636    }
637
638    pub fn mark_cancelled(&mut self) {
639        self.status = WorkflowRunStatus::Cancelled;
640    }
641
642    pub fn mark_budget_exceeded(&mut self) {
643        self.status = WorkflowRunStatus::BudgetExceeded;
644    }
645
646    pub(crate) fn mark_replay_diverged(&mut self) {
647        self.status = WorkflowRunStatus::ReplayDiverged;
648    }
649
650    fn should_stop_mock_execution(&self) -> bool {
651        matches!(
652            self.status,
653            WorkflowRunStatus::Cancelled | WorkflowRunStatus::BudgetExceeded
654        )
655    }
656}
657
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659pub struct MockLeafOutcome {
660    pub status: WorkflowRunStatus,
661    #[serde(default)]
662    pub usage: WorkflowUsage,
663    #[serde(default)]
664    pub memo_usage: WorkflowMemoUsage,
665    #[serde(default)]
666    pub output: Option<String>,
667    #[serde(default)]
668    pub artifacts: Vec<String>,
669}
670
671impl MockLeafOutcome {
672    pub fn succeeded(output: impl Into<String>) -> Self {
673        Self {
674            status: WorkflowRunStatus::Succeeded,
675            usage: WorkflowUsage::default(),
676            memo_usage: WorkflowMemoUsage::default(),
677            output: Some(output.into()),
678            artifacts: Vec::new(),
679        }
680    }
681
682    pub fn failed(output: impl Into<String>) -> Self {
683        Self {
684            status: WorkflowRunStatus::Failed,
685            usage: WorkflowUsage::default(),
686            memo_usage: WorkflowMemoUsage::default(),
687            output: Some(output.into()),
688            artifacts: Vec::new(),
689        }
690    }
691
692    pub fn with_usage(mut self, usage: WorkflowUsage) -> Self {
693        self.usage = usage;
694        self
695    }
696
697    pub fn with_memo_usage(mut self, memo_usage: WorkflowMemoUsage) -> Self {
698        self.memo_usage = memo_usage;
699        self
700    }
701}
702
703#[derive(Debug, Default, Clone)]
704pub struct MockWorkflowExecutor {
705    leaf_outcomes: BTreeMap<String, MockLeafOutcome>,
706    predicate_results: BTreeMap<String, Vec<bool>>,
707    generated_nodes: BTreeMap<String, Vec<WorkflowNode>>,
708    cancelled: bool,
709    max_leaf_steps: Option<u32>,
710    leaf_steps_executed: u32,
711    max_leaf_tokens: Option<u64>,
712    leaf_tokens_used: u64,
713}
714
715impl MockWorkflowExecutor {
716    pub fn new() -> Self {
717        Self::default()
718    }
719
720    pub fn with_leaf_outcome(
721        mut self,
722        leaf_id: impl Into<String>,
723        outcome: MockLeafOutcome,
724    ) -> Self {
725        self.leaf_outcomes.insert(leaf_id.into(), outcome);
726        self
727    }
728
729    pub fn with_predicate_results(
730        mut self,
731        node_id: impl Into<String>,
732        results: Vec<bool>,
733    ) -> Self {
734        self.predicate_results.insert(node_id.into(), results);
735        self
736    }
737
738    pub fn with_generated_nodes(
739        mut self,
740        node_id: impl Into<String>,
741        nodes: Vec<WorkflowNode>,
742    ) -> Self {
743        self.generated_nodes.insert(node_id.into(), nodes);
744        self
745    }
746
747    pub fn with_cancelled(mut self) -> Self {
748        self.cancelled = true;
749        self
750    }
751
752    pub fn with_max_leaf_steps(mut self, max_leaf_steps: u32) -> Self {
753        self.max_leaf_steps = Some(max_leaf_steps);
754        self
755    }
756
757    pub fn with_max_leaf_tokens(mut self, max_leaf_tokens: u64) -> Self {
758        self.max_leaf_tokens = Some(max_leaf_tokens);
759        self
760    }
761
762    pub fn run(
763        &mut self,
764        spec: &WorkflowSpec,
765    ) -> Result<WorkflowExecution, WorkflowExecutionError> {
766        validate_workflow_nodes(&spec.nodes)?;
767        let mut execution = WorkflowExecution::default();
768        self.execute_nodes(&spec.nodes, &mut execution)?;
769        Ok(execution)
770    }
771
772    fn execute_nodes(
773        &mut self,
774        nodes: &[WorkflowNode],
775        execution: &mut WorkflowExecution,
776    ) -> Result<(), WorkflowExecutionError> {
777        for node in nodes {
778            if execution.should_stop_mock_execution() {
779                break;
780            }
781            self.execute_node(node, execution)?;
782        }
783        Ok(())
784    }
785
786    fn execute_node(
787        &mut self,
788        node: &WorkflowNode,
789        execution: &mut WorkflowExecution,
790    ) -> Result<(), WorkflowExecutionError> {
791        match node {
792            WorkflowNode::BranchSet(spec) => self.execute_branch_set(spec, execution),
793            WorkflowNode::Leaf(spec) => {
794                self.execute_leaf(spec, execution);
795                Ok(())
796            }
797            WorkflowNode::Sequence(spec) => {
798                self.execute_nodes(&spec.children, execution)?;
799                execution.control_node_results.push(ControlNodeResult {
800                    node_id: spec.id.clone(),
801                    kind: ControlNodeKind::Sequence,
802                    status: execution.status,
803                    selected_children: spec.children.iter().map(node_id).collect(),
804                    summary: Some("sequence executed in declaration order".to_string()),
805                });
806                Ok(())
807            }
808            WorkflowNode::Reduce(spec) => {
809                execution.control_node_results.push(ControlNodeResult {
810                    node_id: spec.id.clone(),
811                    kind: ControlNodeKind::Reduce,
812                    status: WorkflowRunStatus::Succeeded,
813                    selected_children: spec.inputs.clone(),
814                    summary: Some(spec.prompt.clone()),
815                });
816                Ok(())
817            }
818            WorkflowNode::TeacherReview(spec) => {
819                execution.control_node_results.push(ControlNodeResult {
820                    node_id: spec.id.clone(),
821                    kind: ControlNodeKind::TeacherReview,
822                    status: WorkflowRunStatus::Succeeded,
823                    selected_children: spec.candidates.clone(),
824                    summary: Some(
825                        "teacher review scaffold selected declared candidates".to_string(),
826                    ),
827                });
828                Ok(())
829            }
830            WorkflowNode::LoopUntil(spec) => self.execute_loop_until(spec, execution),
831            WorkflowNode::Cond(spec) => self.execute_cond(spec, execution),
832            WorkflowNode::Expand(spec) => self.execute_expand(spec, execution),
833        }
834    }
835
836    fn execute_branch_set(
837        &mut self,
838        spec: &BranchSpec,
839        execution: &mut WorkflowExecution,
840    ) -> Result<(), WorkflowExecutionError> {
841        let before = execution.leaf_results.len();
842        self.execute_nodes(&spec.children, execution)?;
843        let status = aggregate_mock_status(&execution.leaf_results[before..]);
844        let mut usage = WorkflowUsage::default();
845        let mut memo_usage = WorkflowMemoUsage::default();
846        for result in &execution.leaf_results[before..] {
847            usage.add_assign(result.usage);
848            memo_usage.add_assign(result.memo_usage);
849        }
850        mark_execution_for_status(execution, status);
851        execution.branch_results.push(BranchResult {
852            branch_id: spec.id.clone(),
853            task_id: spec.id.clone(),
854            status,
855            usage,
856            memo_usage,
857            artifacts: Vec::new(),
858            notes: Some("mock branch set executed without runtime fanout".to_string()),
859        });
860        execution.control_node_results.push(ControlNodeResult {
861            node_id: spec.id.clone(),
862            kind: ControlNodeKind::BranchSet,
863            status,
864            selected_children: spec.children.iter().map(node_id).collect(),
865            summary: Some("branch set scaffold executed children deterministically".to_string()),
866        });
867        Ok(())
868    }
869
870    fn execute_leaf(&mut self, spec: &LeafSpec, execution: &mut WorkflowExecution) {
871        let outcome = self.mock_leaf_outcome(spec);
872        mark_execution_for_status(execution, outcome.status);
873        execution.usage.add_assign(outcome.usage);
874        execution.memo_usage.add_assign(outcome.memo_usage);
875        execution.leaf_results.push(LeafResult {
876            leaf_id: spec.id.clone(),
877            task_id: spec.id.clone(),
878            profile: spec.profile.clone(),
879            status: outcome.status,
880            usage: outcome.usage,
881            memo_usage: outcome.memo_usage,
882            output: outcome.output,
883            artifacts: outcome.artifacts,
884            schema_error: None,
885        });
886    }
887
888    fn execute_loop_until(
889        &mut self,
890        spec: &LoopUntilSpec,
891        execution: &mut WorkflowExecution,
892    ) -> Result<(), WorkflowExecutionError> {
893        let max_iterations = spec.max_iterations.unwrap_or(1).max(1);
894        let mut iterations = 0;
895        let mut passed = false;
896        while iterations < max_iterations {
897            if execution.should_stop_mock_execution() {
898                break;
899            }
900            iterations += 1;
901            self.execute_nodes(&spec.children, execution)?;
902            if execution.should_stop_mock_execution() {
903                break;
904            }
905            if self.next_predicate_result(&spec.id) {
906                passed = true;
907                break;
908            }
909        }
910        let status = if execution.should_stop_mock_execution() {
911            execution.status
912        } else if passed {
913            WorkflowRunStatus::Succeeded
914        } else {
915            WorkflowRunStatus::Failed
916        };
917        mark_execution_for_status(execution, status);
918        execution.control_node_results.push(ControlNodeResult {
919            node_id: spec.id.clone(),
920            kind: ControlNodeKind::LoopUntil,
921            status,
922            selected_children: spec.children.iter().map(node_id).collect(),
923            summary: Some(format!("loop_until iterations={iterations}")),
924        });
925        Ok(())
926    }
927
928    fn execute_cond(
929        &mut self,
930        spec: &CondSpec,
931        execution: &mut WorkflowExecution,
932    ) -> Result<(), WorkflowExecutionError> {
933        let passed = self.next_predicate_result(&spec.id);
934        let selected_nodes = if passed {
935            &spec.then_nodes
936        } else {
937            &spec.else_nodes
938        };
939        self.execute_nodes(selected_nodes, execution)?;
940        let status = if execution.should_stop_mock_execution() {
941            execution.status
942        } else {
943            WorkflowRunStatus::Succeeded
944        };
945        execution.control_node_results.push(ControlNodeResult {
946            node_id: spec.id.clone(),
947            kind: ControlNodeKind::Cond,
948            status,
949            selected_children: selected_nodes.iter().map(node_id).collect(),
950            summary: Some(format!("predicate_result={passed}")),
951        });
952        Ok(())
953    }
954
955    fn execute_expand(
956        &mut self,
957        spec: &ExpandSpec,
958        execution: &mut WorkflowExecution,
959    ) -> Result<(), WorkflowExecutionError> {
960        let mut nodes = self.generated_nodes.remove(&spec.id).unwrap_or_default();
961        if let Some(max_children) = spec.max_children {
962            nodes.truncate(max_children);
963        }
964        validate_workflow_node_shapes(&nodes)?;
965        self.execute_nodes(&nodes, execution)?;
966        let status = if execution.should_stop_mock_execution() {
967            execution.status
968        } else {
969            WorkflowRunStatus::Succeeded
970        };
971        execution.control_node_results.push(ControlNodeResult {
972            node_id: spec.id.clone(),
973            kind: ControlNodeKind::Expand,
974            status,
975            selected_children: nodes.iter().map(node_id).collect(),
976            summary: Some(format!("expanded_from={}", spec.source)),
977        });
978        Ok(())
979    }
980
981    fn mock_leaf_outcome(&mut self, spec: &LeafSpec) -> MockLeafOutcome {
982        if self.cancelled {
983            return MockLeafOutcome {
984                status: WorkflowRunStatus::Cancelled,
985                usage: WorkflowUsage::default(),
986                memo_usage: WorkflowMemoUsage::default(),
987                output: Some("mock workflow cancelled before leaf execution".to_string()),
988                artifacts: Vec::new(),
989            };
990        }
991        if self.max_leaf_steps == Some(self.leaf_steps_executed) || spec.budget.max_steps == Some(0)
992        {
993            return MockLeafOutcome {
994                status: WorkflowRunStatus::BudgetExceeded,
995                usage: WorkflowUsage::default(),
996                memo_usage: WorkflowMemoUsage::default(),
997                output: Some("mock workflow leaf step budget exhausted".to_string()),
998                artifacts: Vec::new(),
999            };
1000        }
1001        if self
1002            .max_leaf_tokens
1003            .is_some_and(|max| self.leaf_tokens_used >= max)
1004            || spec.budget.max_tokens == Some(0)
1005        {
1006            return MockLeafOutcome {
1007                status: WorkflowRunStatus::BudgetExceeded,
1008                usage: WorkflowUsage::default(),
1009                memo_usage: WorkflowMemoUsage::default(),
1010                output: Some("mock workflow leaf token budget exhausted".to_string()),
1011                artifacts: Vec::new(),
1012            };
1013        }
1014        self.leaf_steps_executed = self.leaf_steps_executed.saturating_add(1);
1015        let outcome = self
1016            .leaf_outcomes
1017            .remove(&spec.id)
1018            .unwrap_or_else(|| MockLeafOutcome::succeeded(format!("mock leaf {}", spec.id)));
1019        let tokens = outcome.usage.total_tokens();
1020        if let Some(per_leaf_token_cap) = spec.budget.max_tokens
1021            && tokens > per_leaf_token_cap
1022        {
1023            return MockLeafOutcome {
1024                status: WorkflowRunStatus::BudgetExceeded,
1025                usage: outcome.usage,
1026                memo_usage: outcome.memo_usage,
1027                output: Some(format!(
1028                    "mock workflow leaf token budget exhausted ({tokens} > {per_leaf_token_cap})"
1029                )),
1030                artifacts: outcome.artifacts,
1031            };
1032        }
1033        self.leaf_tokens_used = self.leaf_tokens_used.saturating_add(tokens);
1034        outcome
1035    }
1036
1037    fn next_predicate_result(&mut self, node_id: &str) -> bool {
1038        let Some(results) = self.predicate_results.get_mut(node_id) else {
1039            return false;
1040        };
1041        if results.is_empty() {
1042            return false;
1043        }
1044        results.remove(0)
1045    }
1046}
1047
1048fn aggregate_mock_status(results: &[LeafResult]) -> WorkflowRunStatus {
1049    if results
1050        .iter()
1051        .any(|result| result.status == WorkflowRunStatus::Cancelled)
1052    {
1053        WorkflowRunStatus::Cancelled
1054    } else if results
1055        .iter()
1056        .any(|result| result.status == WorkflowRunStatus::BudgetExceeded)
1057    {
1058        WorkflowRunStatus::BudgetExceeded
1059    } else if results
1060        .iter()
1061        .any(|result| result.status != WorkflowRunStatus::Succeeded)
1062    {
1063        WorkflowRunStatus::Failed
1064    } else {
1065        WorkflowRunStatus::Succeeded
1066    }
1067}
1068
1069fn mark_execution_for_status(execution: &mut WorkflowExecution, status: WorkflowRunStatus) {
1070    match status {
1071        WorkflowRunStatus::Succeeded | WorkflowRunStatus::Pending | WorkflowRunStatus::Running => {}
1072        WorkflowRunStatus::Failed => execution.mark_failed(),
1073        WorkflowRunStatus::Cancelled => execution.mark_cancelled(),
1074        WorkflowRunStatus::BudgetExceeded => execution.mark_budget_exceeded(),
1075        WorkflowRunStatus::ReplayDiverged => execution.mark_replay_diverged(),
1076    }
1077}
1078
1079#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080pub struct BranchCandidate {
1081    pub branch_id: String,
1082    pub status: WorkflowRunStatus,
1083    pub score: u32,
1084    pub cost: u64,
1085    #[serde(default)]
1086    pub diversity_key: Option<String>,
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1090#[serde(rename_all = "snake_case")]
1091pub enum TeacherCandidateKind {
1092    Note,
1093    WorkflowRecipe,
1094    SkillPatch,
1095    RegressionTest,
1096    CachePolicyPatch,
1097    BranchHeuristic,
1098    AuthoringPromptPatch,
1099}
1100
1101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1102#[serde(rename_all = "snake_case")]
1103pub enum TeacherCandidateStatus {
1104    #[default]
1105    Proposed,
1106    Accepted,
1107    Rejected,
1108    Promoted,
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct TeacherCandidate {
1113    pub candidate_id: String,
1114    pub kind: TeacherCandidateKind,
1115    #[serde(default)]
1116    pub status: TeacherCandidateStatus,
1117    pub source_node_id: String,
1118    #[serde(default)]
1119    pub source_branch_id: Option<String>,
1120    pub summary: String,
1121    #[serde(default)]
1122    pub evidence: Vec<String>,
1123    #[serde(default)]
1124    pub replay_results: Vec<StudentReplayResult>,
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1128pub struct StudentReplayMetrics {
1129    #[serde(default)]
1130    pub score: i32,
1131    #[serde(default)]
1132    pub cost_microusd: u64,
1133}
1134
1135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136pub struct StudentReplayTestResult {
1137    pub name: String,
1138    pub passed: bool,
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142pub struct StudentReplayResult {
1143    pub trace_id: String,
1144    pub candidate_id: String,
1145    pub baseline: StudentReplayMetrics,
1146    pub candidate: StudentReplayMetrics,
1147    #[serde(default)]
1148    pub required_tests: Vec<StudentReplayTestResult>,
1149    #[serde(default)]
1150    pub policy_violations: Vec<String>,
1151    #[serde(default)]
1152    pub stale: bool,
1153    #[serde(default)]
1154    pub notes: Option<String>,
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1158pub struct PromotionGate {
1159    #[serde(default = "default_min_replay_score_delta")]
1160    pub min_score_delta: i32,
1161    #[serde(default)]
1162    pub max_cost_delta_microusd: Option<i64>,
1163    #[serde(default = "default_true")]
1164    pub require_all_tests_pass: bool,
1165    #[serde(default = "default_true")]
1166    pub reject_policy_violations: bool,
1167    #[serde(default = "default_true")]
1168    pub reject_stale_replay: bool,
1169}
1170
1171impl Default for PromotionGate {
1172    fn default() -> Self {
1173        Self {
1174            min_score_delta: default_min_replay_score_delta(),
1175            max_cost_delta_microusd: None,
1176            require_all_tests_pass: true,
1177            reject_policy_violations: true,
1178            reject_stale_replay: true,
1179        }
1180    }
1181}
1182
1183impl PromotionGate {
1184    pub fn evaluate_candidate(&self, candidate: &TeacherCandidate) -> PromotionGateDecision {
1185        let Some(replay) = candidate.replay_results.last() else {
1186            return PromotionGateDecision {
1187                candidate_id: candidate.candidate_id.clone(),
1188                status: TeacherCandidateStatus::Rejected,
1189                score_delta: 0,
1190                cost_delta_microusd: 0,
1191                reasons: vec!["no student replay result recorded".to_string()],
1192            };
1193        };
1194        self.evaluate_replay(&candidate.candidate_id, replay)
1195    }
1196
1197    pub fn evaluate_replay(
1198        &self,
1199        candidate_id: &str,
1200        replay: &StudentReplayResult,
1201    ) -> PromotionGateDecision {
1202        let score_delta = replay.score_delta();
1203        let cost_delta_microusd = replay.cost_delta_microusd();
1204        let mut reasons = Vec::new();
1205
1206        if score_delta < self.min_score_delta {
1207            reasons.push(format!(
1208                "score delta {score_delta} is below required {}",
1209                self.min_score_delta
1210            ));
1211        }
1212        if let Some(max_cost_delta) = self.max_cost_delta_microusd
1213            && cost_delta_microusd > max_cost_delta
1214        {
1215            reasons.push(format!(
1216                "cost delta {cost_delta_microusd} exceeds allowed {max_cost_delta}"
1217            ));
1218        }
1219        if self.require_all_tests_pass {
1220            for test in replay.required_tests.iter().filter(|test| !test.passed) {
1221                reasons.push(format!("required test `{}` failed", test.name));
1222            }
1223        }
1224        if self.reject_policy_violations {
1225            for violation in &replay.policy_violations {
1226                reasons.push(format!("policy violation: {violation}"));
1227            }
1228        }
1229        if self.reject_stale_replay && replay.stale {
1230            reasons.push("student replay result is stale".to_string());
1231        }
1232
1233        let status = if reasons.is_empty() {
1234            TeacherCandidateStatus::Promoted
1235        } else {
1236            TeacherCandidateStatus::Rejected
1237        };
1238        PromotionGateDecision {
1239            candidate_id: candidate_id.to_string(),
1240            status,
1241            score_delta,
1242            cost_delta_microusd,
1243            reasons,
1244        }
1245    }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1249pub struct PromotionGateDecision {
1250    pub candidate_id: String,
1251    pub status: TeacherCandidateStatus,
1252    pub score_delta: i32,
1253    pub cost_delta_microusd: i64,
1254    #[serde(default)]
1255    pub reasons: Vec<String>,
1256}
1257
1258impl PromotionGateDecision {
1259    pub fn promoted(&self) -> bool {
1260        self.status == TeacherCandidateStatus::Promoted
1261    }
1262}
1263
1264impl StudentReplayResult {
1265    pub fn score_delta(&self) -> i32 {
1266        self.candidate.score.saturating_sub(self.baseline.score)
1267    }
1268
1269    pub fn cost_delta_microusd(&self) -> i64 {
1270        signed_u64_delta(self.candidate.cost_microusd, self.baseline.cost_microusd)
1271    }
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1275pub struct TeacherReviewReport {
1276    pub review_node_id: String,
1277    #[serde(default)]
1278    pub candidates: Vec<TeacherCandidate>,
1279}
1280
1281impl TeacherReviewReport {
1282    pub fn from_execution(review: &TeacherReviewSpec, execution: &WorkflowExecution) -> Self {
1283        let candidates = teacher_candidates_from_execution(review, execution);
1284        Self {
1285            review_node_id: review.id.clone(),
1286            candidates,
1287        }
1288    }
1289}
1290
1291pub fn teacher_candidates_from_execution(
1292    review: &TeacherReviewSpec,
1293    execution: &WorkflowExecution,
1294) -> Vec<TeacherCandidate> {
1295    let mut candidates = Vec::new();
1296    for source in &review.candidates {
1297        if let Some(branch) = execution
1298            .branch_results
1299            .iter()
1300            .find(|branch| branch.branch_id == *source || branch.task_id == *source)
1301        {
1302            candidates.push(teacher_candidate_from_branch(review, branch));
1303            continue;
1304        }
1305        if let Some(leaf) = execution
1306            .leaf_results
1307            .iter()
1308            .find(|leaf| leaf.leaf_id == *source || leaf.task_id == *source)
1309        {
1310            candidates.push(teacher_candidate_from_leaf(review, leaf));
1311            continue;
1312        }
1313        if let Some(control) = execution
1314            .control_node_results
1315            .iter()
1316            .find(|control| control.node_id == *source)
1317        {
1318            candidates.push(teacher_candidate_from_control(review, control));
1319        }
1320    }
1321    candidates
1322}
1323
1324fn teacher_candidate_from_branch(
1325    review: &TeacherReviewSpec,
1326    branch: &BranchResult,
1327) -> TeacherCandidate {
1328    let kind =
1329        if branch.memo_usage.armh_hits > 0 || branch.memo_usage.provider_prompt_cache_hits > 0 {
1330            TeacherCandidateKind::CachePolicyPatch
1331        } else if branch.status == WorkflowRunStatus::Succeeded {
1332            TeacherCandidateKind::WorkflowRecipe
1333        } else {
1334            TeacherCandidateKind::BranchHeuristic
1335        };
1336    let mut evidence = vec![format!("status={:?}", branch.status)];
1337    if branch.usage.total_tokens() > 0 || branch.usage.cost_microusd > 0 {
1338        evidence.push(format!(
1339            "tokens={}, cost_microusd={}",
1340            branch.usage.total_tokens(),
1341            branch.usage.cost_microusd
1342        ));
1343    }
1344    if branch.memo_usage.armh_hits > 0 || branch.memo_usage.provider_prompt_cache_hits > 0 {
1345        evidence.push(format!(
1346            "armh_hits={}, provider_prompt_cache_hits={}",
1347            branch.memo_usage.armh_hits, branch.memo_usage.provider_prompt_cache_hits
1348        ));
1349    }
1350    if let Some(notes) = branch.notes.as_deref() {
1351        evidence.push(format!("notes={notes}"));
1352    }
1353    TeacherCandidate {
1354        candidate_id: format!("{}:{}", review.id, branch.branch_id),
1355        kind,
1356        status: TeacherCandidateStatus::Proposed,
1357        source_node_id: branch.task_id.clone(),
1358        source_branch_id: Some(branch.branch_id.clone()),
1359        summary: format!(
1360            "TeacherReview candidate from branch `{}` with {:?} status.",
1361            branch.branch_id, branch.status
1362        ),
1363        evidence,
1364        replay_results: Vec::new(),
1365    }
1366}
1367
1368fn teacher_candidate_from_leaf(review: &TeacherReviewSpec, leaf: &LeafResult) -> TeacherCandidate {
1369    let kind = if leaf.status == WorkflowRunStatus::Failed {
1370        TeacherCandidateKind::RegressionTest
1371    } else if leaf.memo_usage.armh_hits > 0 || leaf.memo_usage.provider_prompt_cache_hits > 0 {
1372        TeacherCandidateKind::CachePolicyPatch
1373    } else {
1374        TeacherCandidateKind::Note
1375    };
1376    let mut evidence = vec![format!("status={:?}", leaf.status)];
1377    if let Some(output) = leaf.output.as_deref() {
1378        evidence.push(format!("output={}", truncate_evidence(output)));
1379    }
1380    TeacherCandidate {
1381        candidate_id: format!("{}:{}", review.id, leaf.leaf_id),
1382        kind,
1383        status: TeacherCandidateStatus::Proposed,
1384        source_node_id: leaf.leaf_id.clone(),
1385        source_branch_id: None,
1386        summary: format!(
1387            "TeacherReview candidate from leaf `{}` with {:?} status.",
1388            leaf.leaf_id, leaf.status
1389        ),
1390        evidence,
1391        replay_results: Vec::new(),
1392    }
1393}
1394
1395fn teacher_candidate_from_control(
1396    review: &TeacherReviewSpec,
1397    control: &ControlNodeResult,
1398) -> TeacherCandidate {
1399    let mut evidence = vec![format!("status={:?}", control.status)];
1400    if !control.selected_children.is_empty() {
1401        evidence.push(format!(
1402            "selected_children={}",
1403            control.selected_children.join(",")
1404        ));
1405    }
1406    if let Some(summary) = control.summary.as_deref() {
1407        evidence.push(format!("summary={}", truncate_evidence(summary)));
1408    }
1409    TeacherCandidate {
1410        candidate_id: format!("{}:{}", review.id, control.node_id),
1411        kind: TeacherCandidateKind::AuthoringPromptPatch,
1412        status: TeacherCandidateStatus::Proposed,
1413        source_node_id: control.node_id.clone(),
1414        source_branch_id: None,
1415        summary: format!(
1416            "TeacherReview candidate from control node `{}` ({:?}).",
1417            control.node_id, control.kind
1418        ),
1419        evidence,
1420        replay_results: Vec::new(),
1421    }
1422}
1423
1424fn default_min_replay_score_delta() -> i32 {
1425    1
1426}
1427
1428fn default_true() -> bool {
1429    true
1430}
1431
1432fn signed_u64_delta(candidate: u64, baseline: u64) -> i64 {
1433    if candidate >= baseline {
1434        i64::try_from(candidate - baseline).unwrap_or(i64::MAX)
1435    } else {
1436        -i64::try_from(baseline - candidate).unwrap_or(i64::MAX)
1437    }
1438}
1439
1440fn truncate_evidence(value: &str) -> String {
1441    const MAX_EVIDENCE_CHARS: usize = 240;
1442    if value.chars().count() <= MAX_EVIDENCE_CHARS {
1443        return value.to_string();
1444    }
1445    let mut truncated = value
1446        .chars()
1447        .take(MAX_EVIDENCE_CHARS.saturating_sub(1))
1448        .collect::<String>();
1449    truncated.push_str("...");
1450    truncated
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1454pub struct BranchTournament {
1455    #[serde(default)]
1456    pub min_score: u32,
1457}
1458
1459impl BranchTournament {
1460    pub fn select(&self, candidates: &[BranchCandidate]) -> Option<BranchCandidate> {
1461        candidates
1462            .iter()
1463            .filter(|candidate| {
1464                candidate.status == WorkflowRunStatus::Succeeded
1465                    && candidate.score >= self.min_score
1466            })
1467            .min_by_key(|candidate| (candidate.cost, std::cmp::Reverse(candidate.score)))
1468            .cloned()
1469    }
1470}
1471
1472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1473pub struct ParetoFrontier {
1474    #[serde(default = "default_frontier_limit")]
1475    pub max_items: usize,
1476}
1477
1478impl Default for ParetoFrontier {
1479    fn default() -> Self {
1480        Self {
1481            max_items: default_frontier_limit(),
1482        }
1483    }
1484}
1485
1486impl ParetoFrontier {
1487    pub fn select(&self, candidates: &[BranchCandidate]) -> Vec<BranchCandidate> {
1488        let mut frontier: Vec<_> = candidates
1489            .iter()
1490            .filter(|candidate| candidate.status == WorkflowRunStatus::Succeeded)
1491            .filter(|candidate| {
1492                !candidates.iter().any(|other| {
1493                    other.status == WorkflowRunStatus::Succeeded
1494                        && other.score >= candidate.score
1495                        && other.cost <= candidate.cost
1496                        && (other.score > candidate.score || other.cost < candidate.cost)
1497                })
1498            })
1499            .cloned()
1500            .collect();
1501        frontier.sort_by_key(|candidate| (std::cmp::Reverse(candidate.score), candidate.cost));
1502        frontier.truncate(self.max_items.max(1));
1503        frontier
1504    }
1505}
1506
1507#[derive(Debug, Clone, PartialEq, Eq, Error)]
1508pub enum WorkflowExecutionError {
1509    #[error("{kind} node id must not be empty")]
1510    EmptyNodeId { kind: &'static str },
1511    #[error("leaf `{leaf}` prompt must not be empty")]
1512    EmptyLeafPrompt { leaf: String },
1513    #[error(
1514        "leaf `{leaf}` profile `{profile}` must be a non-empty token without whitespace, quotes, or `=`"
1515    )]
1516    InvalidLeafProfile { leaf: String, profile: String },
1517    #[error("duplicate workflow node `{node}`")]
1518    DuplicateNodeId { node: String },
1519    #[error("workflow node `{node}` has unknown {field} reference `{reference}`")]
1520    UnknownNodeReference {
1521        node: String,
1522        field: &'static str,
1523        reference: String,
1524    },
1525}
1526
1527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1528pub struct WorkflowFleetLimits {
1529    pub max_total_agents: usize,
1530    pub max_depth: usize,
1531}
1532
1533impl Default for WorkflowFleetLimits {
1534    fn default() -> Self {
1535        Self {
1536            max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
1537            max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
1538        }
1539    }
1540}
1541
1542#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1543pub struct WorkflowFleetShape {
1544    pub total_agents: usize,
1545    pub max_depth: usize,
1546}
1547
1548impl WorkflowFleetShape {
1549    fn add(self, other: Self) -> Self {
1550        Self {
1551            total_agents: self.total_agents.saturating_add(other.total_agents),
1552            max_depth: self.max_depth.max(other.max_depth),
1553        }
1554    }
1555
1556    fn repeat(self, times: usize) -> Self {
1557        Self {
1558            total_agents: self.total_agents.saturating_mul(times),
1559            max_depth: self.max_depth,
1560        }
1561    }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq, Error)]
1565pub enum WorkflowFleetLimitError {
1566    #[error("workflow IR is invalid for Fleet: {source}")]
1567    InvalidWorkflow {
1568        #[from]
1569        source: WorkflowExecutionError,
1570    },
1571    #[error(
1572        "workflow would launch {total_agents} agents; Fleet Workflow limit is {max_total_agents}"
1573    )]
1574    TooManyAgents {
1575        total_agents: usize,
1576        max_total_agents: usize,
1577    },
1578    #[error("workflow reaches recursion depth {depth}; Fleet Workflow limit is {max_depth}")]
1579    RecursionTooDeep { depth: usize, max_depth: usize },
1580    #[error("expand node `{node}` must declare max_children before Fleet launch")]
1581    UnboundedExpand { node: String },
1582    #[error("expand node `{node}` must include a template before Fleet launch")]
1583    MissingExpandTemplate { node: String },
1584    #[error("loop_until node `{node}` must declare max_iterations before Fleet launch")]
1585    UnboundedLoop { node: String },
1586}
1587
1588fn estimate_fleet_shape(
1589    nodes: &[WorkflowNode],
1590) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1591    estimate_fleet_shape_at_depth(nodes, 1)
1592}
1593
1594fn estimate_fleet_shape_at_depth(
1595    nodes: &[WorkflowNode],
1596    depth: usize,
1597) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1598    nodes
1599        .iter()
1600        .try_fold(WorkflowFleetShape::default(), |shape, node| {
1601            Ok(shape.add(estimate_node_fleet_shape(node, depth)?))
1602        })
1603}
1604
1605fn estimate_node_fleet_shape(
1606    node: &WorkflowNode,
1607    depth: usize,
1608) -> Result<WorkflowFleetShape, WorkflowFleetLimitError> {
1609    match node {
1610        WorkflowNode::Leaf(_) => Ok(WorkflowFleetShape {
1611            total_agents: 1,
1612            max_depth: depth,
1613        }),
1614        WorkflowNode::BranchSet(spec) => estimate_fleet_shape_at_depth(&spec.children, depth + 1),
1615        WorkflowNode::Sequence(spec) => estimate_fleet_shape_at_depth(&spec.children, depth),
1616        WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => Ok(WorkflowFleetShape {
1617            total_agents: 0,
1618            max_depth: 0,
1619        }),
1620        WorkflowNode::LoopUntil(spec) => {
1621            let iterations =
1622                spec.max_iterations
1623                    .ok_or_else(|| WorkflowFleetLimitError::UnboundedLoop {
1624                        node: spec.id.clone(),
1625                    })? as usize;
1626            Ok(estimate_fleet_shape_at_depth(&spec.children, depth)?.repeat(iterations.max(1)))
1627        }
1628        WorkflowNode::Cond(spec) => Ok(estimate_fleet_shape_at_depth(&spec.then_nodes, depth)?
1629            .add(estimate_fleet_shape_at_depth(&spec.else_nodes, depth)?)),
1630        WorkflowNode::Expand(spec) => {
1631            let max_children =
1632                spec.max_children
1633                    .ok_or_else(|| WorkflowFleetLimitError::UnboundedExpand {
1634                        node: spec.id.clone(),
1635                    })?;
1636            let template = spec.template.as_deref().ok_or_else(|| {
1637                WorkflowFleetLimitError::MissingExpandTemplate {
1638                    node: spec.id.clone(),
1639                }
1640            })?;
1641            validate_workflow_node_shapes(std::slice::from_ref(template))
1642                .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?;
1643            Ok(estimate_node_fleet_shape(template, depth)?.repeat(max_children))
1644        }
1645    }
1646}
1647
1648fn default_frontier_limit() -> usize {
1649    8
1650}
1651
1652fn node_id(node: &WorkflowNode) -> String {
1653    match node {
1654        WorkflowNode::BranchSet(spec) => spec.id.clone(),
1655        WorkflowNode::Leaf(spec) => spec.id.clone(),
1656        WorkflowNode::Sequence(spec) => spec.id.clone(),
1657        WorkflowNode::Reduce(spec) => spec.id.clone(),
1658        WorkflowNode::TeacherReview(spec) => spec.id.clone(),
1659        WorkflowNode::LoopUntil(spec) => spec.id.clone(),
1660        WorkflowNode::Cond(spec) => spec.id.clone(),
1661        WorkflowNode::Expand(spec) => spec.id.clone(),
1662    }
1663}
1664
1665pub(crate) fn validate_workflow_nodes(
1666    nodes: &[WorkflowNode],
1667) -> Result<(), WorkflowExecutionError> {
1668    let mut seen = BTreeSet::new();
1669    validate_workflow_nodes_inner(nodes, &mut seen)?;
1670    validate_workflow_references(nodes, &seen)
1671}
1672
1673pub(crate) fn validate_workflow_node_shapes(
1674    nodes: &[WorkflowNode],
1675) -> Result<(), WorkflowExecutionError> {
1676    let mut seen = BTreeSet::new();
1677    validate_workflow_nodes_inner(nodes, &mut seen)
1678}
1679
1680fn validate_workflow_nodes_inner(
1681    nodes: &[WorkflowNode],
1682    seen: &mut BTreeSet<String>,
1683) -> Result<(), WorkflowExecutionError> {
1684    for node in nodes {
1685        let id = node_id(node);
1686        let kind = control_kind_name(node);
1687        if id.trim().is_empty() {
1688            return Err(WorkflowExecutionError::EmptyNodeId { kind });
1689        }
1690        if !seen.insert(id.clone()) {
1691            return Err(WorkflowExecutionError::DuplicateNodeId { node: id });
1692        }
1693        match node {
1694            WorkflowNode::BranchSet(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1695            WorkflowNode::Leaf(spec) => {
1696                if spec.prompt.trim().is_empty() {
1697                    return Err(WorkflowExecutionError::EmptyLeafPrompt {
1698                        leaf: spec.id.clone(),
1699                    });
1700                }
1701                if let Some(profile) = spec.profile.as_deref() {
1702                    validate_leaf_profile(&spec.id, profile)?;
1703                }
1704            }
1705            WorkflowNode::Sequence(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1706            WorkflowNode::LoopUntil(spec) => validate_workflow_nodes_inner(&spec.children, seen)?,
1707            WorkflowNode::Cond(spec) => {
1708                validate_workflow_nodes_inner(&spec.then_nodes, seen)?;
1709                validate_workflow_nodes_inner(&spec.else_nodes, seen)?;
1710            }
1711            WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) | WorkflowNode::Expand(_) => {}
1712        }
1713    }
1714    Ok(())
1715}
1716
1717fn validate_workflow_references(
1718    nodes: &[WorkflowNode],
1719    known_ids: &BTreeSet<String>,
1720) -> Result<(), WorkflowExecutionError> {
1721    for node in nodes {
1722        match node {
1723            WorkflowNode::BranchSet(spec) => {
1724                validate_workflow_references(&spec.children, known_ids)?;
1725            }
1726            WorkflowNode::Leaf(spec) => {
1727                validate_known_references(
1728                    spec.id.as_str(),
1729                    "depends_on_results",
1730                    &spec.depends_on_results,
1731                    known_ids,
1732                )?;
1733            }
1734            WorkflowNode::Sequence(spec) => {
1735                validate_workflow_references(&spec.children, known_ids)?;
1736            }
1737            WorkflowNode::Reduce(spec) => {
1738                validate_known_references(spec.id.as_str(), "inputs", &spec.inputs, known_ids)?;
1739            }
1740            WorkflowNode::TeacherReview(spec) => {
1741                validate_known_references(
1742                    spec.id.as_str(),
1743                    "candidates",
1744                    &spec.candidates,
1745                    known_ids,
1746                )?;
1747            }
1748            WorkflowNode::LoopUntil(spec) => {
1749                validate_workflow_references(&spec.children, known_ids)?;
1750            }
1751            WorkflowNode::Cond(spec) => {
1752                validate_workflow_references(&spec.then_nodes, known_ids)?;
1753                validate_workflow_references(&spec.else_nodes, known_ids)?;
1754            }
1755            WorkflowNode::Expand(_) => {}
1756        }
1757    }
1758    Ok(())
1759}
1760
1761// Token rule only. Roster membership is resolved by the dispatcher (tui crate)
1762// at spawn time; this crate never sees the saved Fleet roster.
1763fn validate_leaf_profile(leaf: &str, profile: &str) -> Result<(), WorkflowExecutionError> {
1764    let invalid = profile.is_empty()
1765        || profile
1766            .chars()
1767            .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
1768    if invalid {
1769        return Err(WorkflowExecutionError::InvalidLeafProfile {
1770            leaf: leaf.to_string(),
1771            profile: profile.to_string(),
1772        });
1773    }
1774    Ok(())
1775}
1776
1777fn validate_known_references(
1778    node: &str,
1779    field: &'static str,
1780    references: &[String],
1781    known_ids: &BTreeSet<String>,
1782) -> Result<(), WorkflowExecutionError> {
1783    for reference in references {
1784        if !known_ids.contains(reference) {
1785            return Err(WorkflowExecutionError::UnknownNodeReference {
1786                node: node.to_string(),
1787                field,
1788                reference: reference.clone(),
1789            });
1790        }
1791    }
1792    Ok(())
1793}
1794
1795fn control_kind_name(node: &WorkflowNode) -> &'static str {
1796    match node {
1797        WorkflowNode::BranchSet(_) => "branch_set",
1798        WorkflowNode::Leaf(_) => "leaf",
1799        WorkflowNode::Sequence(_) => "sequence",
1800        WorkflowNode::Reduce(_) => "reduce",
1801        WorkflowNode::TeacherReview(_) => "teacher_review",
1802        WorkflowNode::LoopUntil(_) => "loop_until",
1803        WorkflowNode::Cond(_) => "cond",
1804        WorkflowNode::Expand(_) => "expand",
1805    }
1806}
1807
1808#[derive(Debug, Clone, PartialEq, Eq, Error)]
1809pub enum WorkflowValidationError {
1810    #[error("{field} must not be empty")]
1811    EmptyField { field: &'static str },
1812    #[error("workflow must contain at least one phase")]
1813    EmptyWorkflow,
1814    #[error("phase `{phase}` must contain at least one task")]
1815    EmptyPhase { phase: String },
1816    #[error("max_concurrent must be between 1 and 20, got {value}")]
1817    InvalidMaxConcurrent { value: u8 },
1818    #[error("duplicate workflow phase `{phase}`")]
1819    DuplicatePhase { phase: String },
1820    #[error("duplicate workflow task `{task}`")]
1821    DuplicateTask { task: String },
1822    #[error("phase `{phase}` has invalid dependency `{dependency}`")]
1823    InvalidPhaseDependency { phase: String, dependency: String },
1824    #[error("phase dependency cycle includes `{phase}`")]
1825    PhaseDependencyCycle { phase: String },
1826    #[error("task `{task}` has invalid result dependency `{dependency}`")]
1827    InvalidTaskResultDependency { task: String, dependency: String },
1828    #[error(
1829        "task `{task}` depends on result `{dependency}` from unavailable phase `{dependency_phase}` while running in `{task_phase}`"
1830    )]
1831    UnavailableTaskResultDependency {
1832        task: String,
1833        dependency: String,
1834        dependency_phase: String,
1835        task_phase: String,
1836    },
1837    #[error("parallel read-write task `{task}` must declare a file_scope")]
1838    MissingParallelWriteScope { task: String },
1839    #[error("parallel read-write tasks `{left}` and `{right}` have overlapping file scopes")]
1840    OverlappingParallelWriteScope { left: String, right: String },
1841}
1842
1843fn default_max_concurrent() -> u8 {
1844    4
1845}
1846
1847fn validate_non_empty(field: &'static str, value: &str) -> Result<(), WorkflowValidationError> {
1848    if value.trim().is_empty() {
1849        return Err(WorkflowValidationError::EmptyField { field });
1850    }
1851    Ok(())
1852}
1853
1854fn ordered_phases(
1855    config: &WorkflowConfig,
1856    phase_indices: &BTreeMap<String, usize>,
1857) -> Result<Vec<String>, WorkflowValidationError> {
1858    let mut visiting = BTreeSet::new();
1859    let mut visited = BTreeSet::new();
1860    let mut ordered = Vec::with_capacity(config.phases.len());
1861
1862    for phase in &config.phases {
1863        visit_phase(
1864            &phase.name,
1865            config,
1866            phase_indices,
1867            &mut visiting,
1868            &mut visited,
1869            &mut ordered,
1870        )?;
1871    }
1872
1873    Ok(ordered)
1874}
1875
1876fn visit_phase(
1877    phase_name: &str,
1878    config: &WorkflowConfig,
1879    phase_indices: &BTreeMap<String, usize>,
1880    visiting: &mut BTreeSet<String>,
1881    visited: &mut BTreeSet<String>,
1882    ordered: &mut Vec<String>,
1883) -> Result<(), WorkflowValidationError> {
1884    if visited.contains(phase_name) {
1885        return Ok(());
1886    }
1887    if !visiting.insert(phase_name.to_string()) {
1888        return Err(WorkflowValidationError::PhaseDependencyCycle {
1889            phase: phase_name.to_string(),
1890        });
1891    }
1892
1893    let phase = &config.phases[phase_indices[phase_name]];
1894    for dependency in &phase.depends_on {
1895        visit_phase(
1896            dependency,
1897            config,
1898            phase_indices,
1899            visiting,
1900            visited,
1901            ordered,
1902        )?;
1903    }
1904
1905    visiting.remove(phase_name);
1906    visited.insert(phase_name.to_string());
1907    ordered.push(phase_name.to_string());
1908    Ok(())
1909}
1910
1911fn validate_parallel_write_scope(phase: &Phase) -> Result<(), WorkflowValidationError> {
1912    if !phase.parallel {
1913        return Ok(());
1914    }
1915
1916    let write_tasks: Vec<_> = phase
1917        .tasks
1918        .iter()
1919        .filter(|task| task.mode == TaskMode::ReadWrite)
1920        .collect();
1921
1922    for task in &write_tasks {
1923        if task.file_scope.is_empty() {
1924            return Err(WorkflowValidationError::MissingParallelWriteScope {
1925                task: task.id.clone(),
1926            });
1927        }
1928    }
1929
1930    for (left_index, left) in write_tasks.iter().enumerate() {
1931        for right in write_tasks.iter().skip(left_index + 1) {
1932            if scopes_overlap(&left.file_scope, &right.file_scope) {
1933                return Err(WorkflowValidationError::OverlappingParallelWriteScope {
1934                    left: left.id.clone(),
1935                    right: right.id.clone(),
1936                });
1937            }
1938        }
1939    }
1940
1941    Ok(())
1942}
1943
1944pub fn scopes_overlap(left: &[String], right: &[String]) -> bool {
1945    left.iter().any(|left_scope| {
1946        right
1947            .iter()
1948            .any(|right_scope| scope_overlaps(left_scope, right_scope))
1949    })
1950}
1951
1952fn scope_overlaps(left: &str, right: &str) -> bool {
1953    let left = normalize_scope(left);
1954    let right = normalize_scope(right);
1955
1956    if left == right || left == "." || right == "." {
1957        return true;
1958    }
1959
1960    if left.contains('*') || right.contains('*') {
1961        return glob_prefix(&left) == glob_prefix(&right);
1962    }
1963
1964    let left_path = Path::new(&left);
1965    let right_path = Path::new(&right);
1966    left_path.starts_with(right_path) || right_path.starts_with(left_path)
1967}
1968
1969fn normalize_scope(scope: &str) -> String {
1970    let trimmed = scope.trim().trim_start_matches("./").trim_end_matches('/');
1971    trimmed
1972        .strip_suffix("/**")
1973        .or_else(|| trimmed.strip_suffix("/*"))
1974        .unwrap_or(trimmed)
1975        .to_string()
1976}
1977
1978fn glob_prefix(scope: &str) -> String {
1979    scope
1980        .split('*')
1981        .next()
1982        .unwrap_or(scope)
1983        .trim_end_matches('/')
1984        .to_string()
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989    use super::*;
1990
1991    fn task(id: &str) -> Task {
1992        Task {
1993            id: id.to_string(),
1994            prompt: format!("run {id}"),
1995            agent_type: AgentType::General,
1996            mode: TaskMode::ReadOnly,
1997            isolation: IsolationMode::Shared,
1998            file_scope: Vec::new(),
1999            depends_on_results: Vec::new(),
2000            max_steps: None,
2001            timeout_secs: None,
2002        }
2003    }
2004
2005    fn config(phases: Vec<Phase>) -> WorkflowConfig {
2006        WorkflowConfig {
2007            goal: "cache-change".to_string(),
2008            max_concurrent: 4,
2009            description: None,
2010            phases,
2011        }
2012    }
2013
2014    fn phase(name: &str, depends_on: &[&str], tasks: Vec<Task>) -> Phase {
2015        Phase {
2016            name: name.to_string(),
2017            description: None,
2018            depends_on: depends_on.iter().map(|value| value.to_string()).collect(),
2019            parallel: false,
2020            on_failure: FailurePolicy::SkipContinue,
2021            tasks,
2022        }
2023    }
2024
2025    fn leaf_node(id: &str) -> WorkflowNode {
2026        WorkflowNode::Leaf(LeafSpec {
2027            id: id.to_string(),
2028            prompt: format!("run {id}"),
2029            agent_type: AgentType::General,
2030            profile: None,
2031            mode: TaskMode::ReadOnly,
2032            isolation: IsolationMode::Shared,
2033            file_scope: Vec::new(),
2034            depends_on_results: Vec::new(),
2035            budget: BudgetSpec::default(),
2036            permissions: PermissionSpec::default(),
2037            model_policy: ModelPolicy::default(),
2038        })
2039    }
2040
2041    fn leaf_node_with_budget(id: &str, budget: BudgetSpec) -> WorkflowNode {
2042        WorkflowNode::Leaf(LeafSpec {
2043            id: id.to_string(),
2044            prompt: format!("run {id}"),
2045            agent_type: AgentType::General,
2046            profile: None,
2047            mode: TaskMode::ReadOnly,
2048            isolation: IsolationMode::Shared,
2049            file_scope: Vec::new(),
2050            depends_on_results: Vec::new(),
2051            budget,
2052            permissions: PermissionSpec::default(),
2053            model_policy: ModelPolicy::default(),
2054        })
2055    }
2056
2057    fn invalid_leaf_node(id: &str) -> WorkflowNode {
2058        WorkflowNode::Leaf(LeafSpec {
2059            id: id.to_string(),
2060            prompt: " ".to_string(),
2061            agent_type: AgentType::General,
2062            profile: None,
2063            mode: TaskMode::ReadOnly,
2064            isolation: IsolationMode::Shared,
2065            file_scope: Vec::new(),
2066            depends_on_results: Vec::new(),
2067            budget: BudgetSpec::default(),
2068            permissions: PermissionSpec::default(),
2069            model_policy: ModelPolicy::default(),
2070        })
2071    }
2072
2073    fn workflow_spec(nodes: Vec<WorkflowNode>) -> WorkflowSpec {
2074        WorkflowSpec {
2075            id: Some("mock-workflow".to_string()),
2076            goal: "prove mock executor control flow".to_string(),
2077            description: None,
2078            budget: BudgetSpec::default(),
2079            permissions: PermissionSpec::default(),
2080            model_policy: ModelPolicy::default(),
2081            promotion_policy: PromotionPolicy::default(),
2082            nodes,
2083        }
2084    }
2085
2086    fn control_result<'a>(
2087        execution: &'a WorkflowExecution,
2088        node_id: &str,
2089    ) -> &'a ControlNodeResult {
2090        execution
2091            .control_node_results
2092            .iter()
2093            .find(|result| result.node_id == node_id)
2094            .expect("control node result should exist")
2095    }
2096
2097    fn candidate(
2098        branch_id: &str,
2099        status: WorkflowRunStatus,
2100        score: u32,
2101        cost: u64,
2102        diversity_key: &str,
2103    ) -> BranchCandidate {
2104        BranchCandidate {
2105            branch_id: branch_id.to_string(),
2106            status,
2107            score,
2108            cost,
2109            diversity_key: Some(diversity_key.to_string()),
2110        }
2111    }
2112
2113    #[test]
2114    fn independent_phases_preserve_declaration_order() {
2115        let workflow = config(vec![
2116            phase("discover", &[], vec![task("scan")]),
2117            phase("report", &[], vec![task("summarize")]),
2118        ]);
2119
2120        let plan = workflow.compile().expect("workflow should compile");
2121
2122        assert_eq!(
2123            plan.phase_names().collect::<Vec<_>>(),
2124            vec!["discover", "report"]
2125        );
2126    }
2127
2128    #[test]
2129    fn dependencies_override_declaration_order_deterministically() {
2130        let workflow = config(vec![
2131            phase("review", &["implement"], vec![task("review-results")]),
2132            phase("discover", &[], vec![task("scan")]),
2133            phase("implement", &["discover"], vec![task("patch")]),
2134            phase("report", &["review"], vec![task("summarize")]),
2135        ]);
2136
2137        let plan = workflow.compile().expect("workflow should compile");
2138
2139        assert_eq!(
2140            plan.phase_names().collect::<Vec<_>>(),
2141            vec!["discover", "implement", "review", "report"]
2142        );
2143    }
2144
2145    #[test]
2146    fn rejects_empty_workflow() {
2147        let err = config(Vec::new())
2148            .validate()
2149            .expect_err("empty workflow should fail");
2150
2151        assert_eq!(err, WorkflowValidationError::EmptyWorkflow);
2152    }
2153
2154    #[test]
2155    fn rejects_empty_phase() {
2156        let err = config(vec![phase("empty", &[], Vec::new())])
2157            .validate()
2158            .expect_err("empty phase should fail");
2159
2160        assert_eq!(
2161            err,
2162            WorkflowValidationError::EmptyPhase {
2163                phase: "empty".to_string()
2164            }
2165        );
2166    }
2167
2168    #[test]
2169    fn rejects_invalid_max_concurrent() {
2170        let mut workflow = config(vec![phase("discover", &[], vec![task("scan")])]);
2171        workflow.max_concurrent = 0;
2172
2173        let err = workflow
2174            .validate()
2175            .expect_err("zero concurrency should fail");
2176
2177        assert_eq!(
2178            err,
2179            WorkflowValidationError::InvalidMaxConcurrent { value: 0 }
2180        );
2181    }
2182
2183    #[test]
2184    fn rejects_duplicate_phase_names() {
2185        let err = config(vec![
2186            phase("discover", &[], vec![task("scan")]),
2187            phase("discover", &[], vec![task("scan-again")]),
2188        ])
2189        .validate()
2190        .expect_err("duplicate phase should fail");
2191
2192        assert!(matches!(
2193            err,
2194            WorkflowValidationError::DuplicatePhase { .. }
2195        ));
2196    }
2197
2198    #[test]
2199    fn rejects_duplicate_task_ids() {
2200        let err = config(vec![
2201            phase("discover", &[], vec![task("scan")]),
2202            phase("report", &[], vec![task("scan")]),
2203        ])
2204        .validate()
2205        .expect_err("duplicate task should fail");
2206
2207        assert!(matches!(err, WorkflowValidationError::DuplicateTask { .. }));
2208    }
2209
2210    #[test]
2211    fn rejects_unknown_phase_dependency() {
2212        let err = config(vec![phase("report", &["missing"], vec![task("summarize")])])
2213            .validate()
2214            .expect_err("unknown dependency should fail");
2215
2216        assert!(matches!(
2217            err,
2218            WorkflowValidationError::InvalidPhaseDependency { .. }
2219        ));
2220    }
2221
2222    #[test]
2223    fn rejects_phase_dependency_cycles() {
2224        let workflow = config(vec![
2225            phase("a", &["b"], vec![task("a-task")]),
2226            phase("b", &["a"], vec![task("b-task")]),
2227        ]);
2228
2229        let err = workflow.validate().expect_err("cycle should fail");
2230
2231        assert!(matches!(
2232            err,
2233            WorkflowValidationError::PhaseDependencyCycle { .. }
2234        ));
2235    }
2236
2237    #[test]
2238    fn rejects_task_result_dependency_from_same_parallel_phase() {
2239        let mut first = task("first");
2240        first.depends_on_results.push("second".to_string());
2241        let mut parallel = phase("parallel", &[], vec![first, task("second")]);
2242        parallel.parallel = true;
2243
2244        let err = config(vec![parallel])
2245            .validate()
2246            .expect_err("same-phase result dependency should fail");
2247
2248        assert!(matches!(
2249            err,
2250            WorkflowValidationError::UnavailableTaskResultDependency { .. }
2251        ));
2252    }
2253
2254    #[test]
2255    fn rejects_task_result_dependency_from_later_phase() {
2256        let mut summarize = task("summarize");
2257        summarize.depends_on_results.push("scan".to_string());
2258        let workflow = config(vec![
2259            phase("report", &[], vec![summarize]),
2260            phase("discover", &[], vec![task("scan")]),
2261        ]);
2262
2263        let err = workflow
2264            .validate()
2265            .expect_err("later-phase result dependency should fail");
2266
2267        assert!(matches!(
2268            err,
2269            WorkflowValidationError::UnavailableTaskResultDependency { .. }
2270        ));
2271    }
2272
2273    #[test]
2274    fn allows_task_result_dependency_from_earlier_phase() {
2275        let upstream = phase("discover", &[], vec![task("scan")]);
2276        let mut summarize = task("summarize");
2277        summarize.depends_on_results.push("scan".to_string());
2278        let downstream = phase("report", &["discover"], vec![summarize]);
2279
2280        config(vec![upstream, downstream])
2281            .validate()
2282            .expect("earlier-phase result should be available");
2283    }
2284
2285    #[test]
2286    fn rejects_parallel_read_write_without_file_scope() {
2287        let mut write = task("write");
2288        write.mode = TaskMode::ReadWrite;
2289        let mut parallel = phase("parallel", &[], vec![write]);
2290        parallel.parallel = true;
2291
2292        let err = config(vec![parallel])
2293            .validate()
2294            .expect_err("write task needs a scope");
2295
2296        assert!(matches!(
2297            err,
2298            WorkflowValidationError::MissingParallelWriteScope { .. }
2299        ));
2300    }
2301
2302    #[test]
2303    fn detects_overlapping_parallel_write_scopes_with_path_boundaries() {
2304        let mut left = task("auth");
2305        left.mode = TaskMode::ReadWrite;
2306        left.file_scope = vec!["src/auth/**".to_string()];
2307        let mut right = task("auth-login");
2308        right.mode = TaskMode::ReadWrite;
2309        right.file_scope = vec!["src/auth/login.rs".to_string()];
2310        let mut parallel = phase("parallel", &[], vec![left, right]);
2311        parallel.parallel = true;
2312
2313        let err = config(vec![parallel])
2314            .validate()
2315            .expect_err("nested scopes should overlap");
2316
2317        assert!(matches!(
2318            err,
2319            WorkflowValidationError::OverlappingParallelWriteScope { .. }
2320        ));
2321    }
2322
2323    #[test]
2324    fn does_not_confuse_path_prefixes_for_overlapping_scopes() {
2325        let mut left = task("auth");
2326        left.mode = TaskMode::ReadWrite;
2327        left.file_scope = vec!["src/auth/**".to_string()];
2328        let mut right = task("auth-admin");
2329        right.mode = TaskMode::ReadWrite;
2330        right.file_scope = vec!["src/auth_admin/**".to_string()];
2331        let mut parallel = phase("parallel", &[], vec![left, right]);
2332        parallel.parallel = true;
2333
2334        config(vec![parallel])
2335            .validate()
2336            .expect("component boundary scopes should not overlap");
2337    }
2338
2339    #[test]
2340    fn json_roundtrip_keeps_snake_case_enum_names() {
2341        let mut task = task("patch");
2342        task.agent_type = AgentType::Implementer;
2343        task.mode = TaskMode::ReadWrite;
2344        task.isolation = IsolationMode::Worktree;
2345        task.file_scope = vec!["src/auth/**".to_string()];
2346        let mut parallel = phase("implement", &[], vec![task]);
2347        parallel.parallel = true;
2348        parallel.on_failure = FailurePolicy::Abort;
2349        let workflow = config(vec![parallel]);
2350
2351        let json = serde_json::to_string(&workflow).expect("serialize workflow");
2352
2353        assert!(json.contains("\"agent_type\":\"implementer\""));
2354        assert!(json.contains("\"mode\":\"read_write\""));
2355        assert!(json.contains("\"isolation\":\"worktree\""));
2356        assert!(json.contains("\"on_failure\":\"abort\""));
2357
2358        let parsed: WorkflowConfig = serde_json::from_str(&json).expect("parse workflow");
2359        assert_eq!(parsed, workflow);
2360    }
2361
2362    #[test]
2363    fn workflow_ir_roundtrip() {
2364        let discover_leaf = LeafSpec {
2365            id: "scan-readme".to_string(),
2366            prompt: "Inspect README setup gaps".to_string(),
2367            agent_type: AgentType::Explore,
2368            profile: Some("scout".to_string()),
2369            mode: TaskMode::ReadOnly,
2370            isolation: IsolationMode::Shared,
2371            file_scope: vec!["README.md".to_string()],
2372            depends_on_results: Vec::new(),
2373            budget: BudgetSpec {
2374                max_steps: Some(8),
2375                timeout_secs: Some(300),
2376                max_parallel: None,
2377                max_tokens: None,
2378            },
2379            permissions: PermissionSpec::default(),
2380            model_policy: ModelPolicy {
2381                provider: Some("openai".to_string()),
2382                model: Some("gpt-5.4".to_string()),
2383                fallback_models: Vec::new(),
2384            },
2385        };
2386        let workflow = WorkflowSpec {
2387            id: Some("v090-readme-check".to_string()),
2388            goal: "tighten setup docs".to_string(),
2389            description: Some("metadata-only typed Workflow IR".to_string()),
2390            budget: BudgetSpec {
2391                max_steps: Some(30),
2392                timeout_secs: Some(1_800),
2393                max_parallel: Some(2),
2394                max_tokens: None,
2395            },
2396            permissions: PermissionSpec {
2397                allow_write: false,
2398                allow_network: false,
2399                allowed_tools: vec!["rg".to_string()],
2400                file_scope: vec!["README.md".to_string()],
2401            },
2402            model_policy: ModelPolicy {
2403                provider: Some("openai".to_string()),
2404                model: Some("gpt-5.4".to_string()),
2405                fallback_models: vec!["gpt-5.4-mini".to_string()],
2406            },
2407            promotion_policy: PromotionPolicy {
2408                strategy: PromotionStrategy::TeacherSelected,
2409                require_teacher_review: true,
2410                min_successful_branches: Some(1),
2411                promotion_gate: PromotionGate::default(),
2412            },
2413            nodes: vec![
2414                WorkflowNode::BranchSet(BranchSpec {
2415                    id: "discover".to_string(),
2416                    description: Some("parallel doc inspection".to_string()),
2417                    parallel: true,
2418                    budget: BudgetSpec {
2419                        max_steps: Some(12),
2420                        timeout_secs: Some(600),
2421                        max_parallel: Some(2),
2422                        max_tokens: None,
2423                    },
2424                    permissions: PermissionSpec::default(),
2425                    model_policy: ModelPolicy::default(),
2426                    children: vec![WorkflowNode::Leaf(discover_leaf)],
2427                }),
2428                WorkflowNode::Sequence(SequenceSpec {
2429                    id: "review-and-reduce".to_string(),
2430                    children: vec![
2431                        WorkflowNode::TeacherReview(TeacherReviewSpec {
2432                            id: "select-best".to_string(),
2433                            candidates: vec!["scan-readme".to_string()],
2434                            promotion_policy: PromotionPolicy {
2435                                strategy: PromotionStrategy::BestScore,
2436                                require_teacher_review: true,
2437                                min_successful_branches: Some(1),
2438                                promotion_gate: PromotionGate::default(),
2439                            },
2440                        }),
2441                        WorkflowNode::Reduce(ReduceSpec {
2442                            id: "summarize".to_string(),
2443                            inputs: vec!["scan-readme".to_string()],
2444                            prompt: "Summarize the smallest safe patch".to_string(),
2445                            model_policy: ModelPolicy::default(),
2446                        }),
2447                    ],
2448                }),
2449                WorkflowNode::Cond(CondSpec {
2450                    id: "maybe-expand".to_string(),
2451                    condition: "summary identifies multiple independent gaps".to_string(),
2452                    then_nodes: vec![WorkflowNode::Expand(ExpandSpec {
2453                        id: "split-followups".to_string(),
2454                        source: "summarize".to_string(),
2455                        max_children: None,
2456                        template: Some(Box::new(WorkflowNode::Leaf(LeafSpec {
2457                            id: "followup-template".to_string(),
2458                            prompt: "Patch one independent gap".to_string(),
2459                            agent_type: AgentType::Implementer,
2460                            profile: None,
2461                            mode: TaskMode::ReadWrite,
2462                            isolation: IsolationMode::Worktree,
2463                            file_scope: vec!["README.md".to_string()],
2464                            depends_on_results: Vec::new(),
2465                            budget: BudgetSpec::default(),
2466                            permissions: PermissionSpec {
2467                                allow_write: true,
2468                                allow_network: false,
2469                                allowed_tools: Vec::new(),
2470                                file_scope: vec!["README.md".to_string()],
2471                            },
2472                            model_policy: ModelPolicy::default(),
2473                        }))),
2474                    })],
2475                    else_nodes: vec![WorkflowNode::LoopUntil(LoopUntilSpec {
2476                        id: "verify-once".to_string(),
2477                        condition: "local verification passes".to_string(),
2478                        max_iterations: Some(1),
2479                        children: Vec::new(),
2480                    })],
2481                }),
2482            ],
2483        };
2484
2485        let json = serde_json::to_string_pretty(&workflow).expect("serialize workflow ir");
2486
2487        assert!(json.contains("\"kind\": \"branch_set\""));
2488        assert!(json.contains("\"strategy\": \"teacher_selected\""));
2489        assert!(json.contains("\"profile\": \"scout\""));
2490        let parsed: WorkflowSpec = serde_json::from_str(&json).expect("parse workflow ir");
2491        assert_eq!(parsed, workflow);
2492
2493        let minimal: WorkflowSpec = serde_json::from_str(r#"{"goal":"ship v0.9","nodes":[]}"#)
2494            .expect("parse minimal workflow ir");
2495        assert_eq!(minimal.budget, BudgetSpec::default());
2496        assert_eq!(minimal.permissions, PermissionSpec::default());
2497        assert_eq!(minimal.model_policy, ModelPolicy::default());
2498
2499        // Pre-profile leaf IR stays parseable and profile-less leaves omit the key.
2500        let legacy_leaf: LeafSpec = serde_json::from_str(r#"{"id":"scan","prompt":"scan safely"}"#)
2501            .expect("parse pre-profile leaf ir");
2502        assert_eq!(legacy_leaf.profile, None);
2503        let legacy_json = serde_json::to_string(&legacy_leaf).expect("serialize legacy leaf");
2504        assert!(!legacy_json.contains("profile"));
2505    }
2506
2507    #[test]
2508    fn fleet_validation_accepts_one_hundred_agents_and_variable_models() {
2509        let nodes = (0..DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
2510            .map(|index| {
2511                let mut leaf = match leaf_node(&format!("agent-{index}")) {
2512                    WorkflowNode::Leaf(leaf) => leaf,
2513                    _ => unreachable!("leaf helper returns a leaf"),
2514                };
2515                leaf.model_policy = if index == 0 {
2516                    ModelPolicy {
2517                        provider: Some("deepseek".to_string()),
2518                        model: Some("deepseek-v4-pro".to_string()),
2519                        fallback_models: Vec::new(),
2520                    }
2521                } else {
2522                    ModelPolicy {
2523                        provider: Some("deepseek".to_string()),
2524                        model: Some("deepseek-v4-flash".to_string()),
2525                        fallback_models: Vec::new(),
2526                    }
2527                };
2528                WorkflowNode::Leaf(leaf)
2529            })
2530            .collect();
2531        let workflow = workflow_spec(nodes);
2532
2533        let shape = workflow
2534            .validate_for_fleet()
2535            .expect("one hundred agents should fit the Fleet Workflow limit");
2536
2537        assert_eq!(shape.total_agents, DEFAULT_FLEET_WORKFLOW_MAX_AGENTS);
2538        assert_eq!(shape.max_depth, 1);
2539    }
2540
2541    #[test]
2542    fn fleet_validation_rejects_more_than_one_hundred_agents() {
2543        let nodes = (0..=DEFAULT_FLEET_WORKFLOW_MAX_AGENTS)
2544            .map(|index| leaf_node(&format!("agent-{index}")))
2545            .collect();
2546        let workflow = workflow_spec(nodes);
2547
2548        let err = workflow
2549            .validate_for_fleet()
2550            .expect_err("agent population should be bounded before Fleet launch");
2551
2552        assert_eq!(
2553            err,
2554            WorkflowFleetLimitError::TooManyAgents {
2555                total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS + 1,
2556                max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS,
2557            }
2558        );
2559    }
2560
2561    #[test]
2562    fn fleet_validation_rejects_depth_beyond_five() {
2563        let mut node = leaf_node("deep-leaf");
2564        for depth in (0..DEFAULT_FLEET_WORKFLOW_MAX_DEPTH).rev() {
2565            node = WorkflowNode::BranchSet(BranchSpec {
2566                id: format!("ring-{depth}"),
2567                description: None,
2568                parallel: true,
2569                budget: BudgetSpec::default(),
2570                permissions: PermissionSpec::default(),
2571                model_policy: ModelPolicy::default(),
2572                children: vec![node],
2573            });
2574        }
2575        let workflow = workflow_spec(vec![node]);
2576
2577        let err = workflow
2578            .validate_for_fleet()
2579            .expect_err("sixth agent ring should be rejected");
2580
2581        assert_eq!(
2582            err,
2583            WorkflowFleetLimitError::RecursionTooDeep {
2584                depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH + 1,
2585                max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH,
2586            }
2587        );
2588    }
2589
2590    #[test]
2591    fn fleet_validation_counts_loop_and_expand_fanout_conservatively() {
2592        let workflow = workflow_spec(vec![
2593            WorkflowNode::LoopUntil(LoopUntilSpec {
2594                id: "retry-ring".to_string(),
2595                condition: "verifier passes".to_string(),
2596                max_iterations: Some(3),
2597                children: vec![leaf_node("retry-worker")],
2598            }),
2599            WorkflowNode::Expand(ExpandSpec {
2600                id: "split".to_string(),
2601                source: "retry-ring".to_string(),
2602                max_children: Some(4),
2603                template: Some(Box::new(leaf_node("split-template"))),
2604            }),
2605        ]);
2606
2607        let shape = workflow
2608            .validate_for_fleet()
2609            .expect("bounded loop and expand should validate");
2610
2611        assert_eq!(shape.total_agents, 7);
2612        assert_eq!(shape.max_depth, 1);
2613    }
2614
2615    #[test]
2616    fn fleet_validation_rejects_unbounded_loop_or_expand_before_launch() {
2617        let workflow = workflow_spec(vec![
2618            WorkflowNode::LoopUntil(LoopUntilSpec {
2619                id: "retry-ring".to_string(),
2620                condition: "verifier passes".to_string(),
2621                max_iterations: None,
2622                children: vec![leaf_node("retry-worker")],
2623            }),
2624            WorkflowNode::Expand(ExpandSpec {
2625                id: "split".to_string(),
2626                source: "retry-ring".to_string(),
2627                max_children: Some(4),
2628                template: Some(Box::new(leaf_node("split-template"))),
2629            }),
2630        ]);
2631
2632        assert!(matches!(
2633            workflow.validate_for_fleet(),
2634            Err(WorkflowFleetLimitError::UnboundedLoop { node }) if node == "retry-ring"
2635        ));
2636
2637        let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
2638            id: "split".to_string(),
2639            source: "retry-ring".to_string(),
2640            max_children: None,
2641            template: Some(Box::new(leaf_node("split-template"))),
2642        })]);
2643
2644        assert!(matches!(
2645            workflow.validate_for_fleet(),
2646            Err(WorkflowFleetLimitError::UnboundedExpand { node }) if node == "split"
2647        ));
2648    }
2649
2650    #[test]
2651    fn branch_result_serialization() {
2652        let result = BranchResult {
2653            branch_id: "discover".to_string(),
2654            task_id: "scan".to_string(),
2655            status: WorkflowRunStatus::Succeeded,
2656            usage: WorkflowUsage {
2657                input_tokens: 100,
2658                output_tokens: 25,
2659                cost_microusd: 42,
2660            },
2661            memo_usage: WorkflowMemoUsage::default(),
2662            artifacts: vec!["trace://branches/discover".to_string()],
2663            notes: Some("validated prompt surfaces".to_string()),
2664        };
2665
2666        let json = serde_json::to_string(&result).expect("serialize branch result");
2667
2668        assert!(json.contains("\"status\":\"succeeded\""));
2669        assert!(json.contains("\"cost_microusd\":42"));
2670        let parsed: BranchResult = serde_json::from_str(&json).expect("parse branch result");
2671        assert_eq!(parsed, result);
2672
2673        let minimal: BranchResult =
2674            serde_json::from_str(r#"{"branch_id":"discover","task_id":"scan","status":"pending"}"#)
2675                .expect("parse minimal branch result");
2676        assert_eq!(minimal.usage, WorkflowUsage::default());
2677        assert_eq!(minimal.memo_usage, WorkflowMemoUsage::default());
2678        assert!(minimal.artifacts.is_empty());
2679        assert_eq!(minimal.notes, None);
2680    }
2681
2682    #[test]
2683    fn leaf_result_serialization() {
2684        let result = LeafResult {
2685            leaf_id: "scan-readme".to_string(),
2686            task_id: "scan".to_string(),
2687            profile: Some("reviewer".to_string()),
2688            status: WorkflowRunStatus::Failed,
2689            usage: WorkflowUsage {
2690                input_tokens: 11,
2691                output_tokens: 7,
2692                cost_microusd: 3,
2693            },
2694            memo_usage: WorkflowMemoUsage {
2695                armh_hits: 1,
2696                armh_misses: 0,
2697                armh_saved_estimated_tokens: 128,
2698                provider_prompt_cache_hits: 2,
2699                provider_prompt_cache_misses: 1,
2700            },
2701            output: Some("README needs clearer setup steps".to_string()),
2702            artifacts: vec!["trace://leaves/scan-readme".to_string()],
2703            schema_error: None,
2704        };
2705
2706        let json = serde_json::to_string(&result).expect("serialize leaf result");
2707
2708        assert!(json.contains("\"status\":\"failed\""));
2709        assert!(json.contains("\"input_tokens\":11"));
2710        assert!(json.contains("\"armh_saved_estimated_tokens\":128"));
2711        assert!(json.contains("\"profile\":\"reviewer\""));
2712        let parsed: LeafResult = serde_json::from_str(&json).expect("parse leaf result");
2713        assert_eq!(parsed, result);
2714
2715        let minimal: LeafResult = serde_json::from_str(
2716            r#"{"leaf_id":"scan-readme","task_id":"scan","status":"pending"}"#,
2717        )
2718        .expect("parse minimal leaf result");
2719        assert_eq!(minimal.profile, None);
2720        assert_eq!(minimal.usage, WorkflowUsage::default());
2721        assert_eq!(minimal.memo_usage, WorkflowMemoUsage::default());
2722        assert_eq!(minimal.output, None);
2723        assert!(minimal.artifacts.is_empty());
2724    }
2725
2726    #[test]
2727    fn control_node_result_serialization() {
2728        let result = ControlNodeResult {
2729            node_id: "select-fix".to_string(),
2730            kind: ControlNodeKind::TeacherReview,
2731            status: WorkflowRunStatus::Running,
2732            selected_children: vec!["branch-a".to_string(), "branch-c".to_string()],
2733            summary: Some("teacher review is waiting on verifier evidence".to_string()),
2734        };
2735
2736        let json = serde_json::to_string(&result).expect("serialize control node result");
2737
2738        assert!(json.contains("\"kind\":\"teacher_review\""));
2739        assert!(json.contains("\"status\":\"running\""));
2740        let parsed: ControlNodeResult =
2741            serde_json::from_str(&json).expect("parse control node result");
2742        assert_eq!(parsed, result);
2743
2744        let minimal: ControlNodeResult = serde_json::from_str(
2745            r#"{"node_id":"select-fix","kind":"branch_set","status":"pending"}"#,
2746        )
2747        .expect("parse minimal control node result");
2748        assert!(minimal.selected_children.is_empty());
2749        assert_eq!(minimal.summary, None);
2750    }
2751
2752    #[test]
2753    fn run_mock_three_branch_workflow() {
2754        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2755            id: "discover".to_string(),
2756            description: None,
2757            parallel: true,
2758            budget: BudgetSpec::default(),
2759            permissions: PermissionSpec::default(),
2760            model_policy: ModelPolicy::default(),
2761            children: vec![
2762                leaf_node("scan-readme"),
2763                leaf_node("scan-config"),
2764                leaf_node("scan-tests"),
2765            ],
2766        })]);
2767
2768        let mut executor = MockWorkflowExecutor::new();
2769        let execution = executor.run(&workflow).expect("mock workflow should run");
2770
2771        assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
2772        assert_eq!(
2773            execution
2774                .leaf_results
2775                .iter()
2776                .map(|result| result.leaf_id.as_str())
2777                .collect::<Vec<_>>(),
2778            vec!["scan-readme", "scan-config", "scan-tests"]
2779        );
2780        assert_eq!(execution.branch_results.len(), 1);
2781        assert_eq!(execution.branch_results[0].branch_id, "discover");
2782        assert_eq!(
2783            control_result(&execution, "discover").selected_children,
2784            vec!["scan-readme", "scan-config", "scan-tests"]
2785        );
2786    }
2787
2788    #[test]
2789    fn mock_executor_surfaces_leaf_profile() {
2790        let mut profiled_leaf = match leaf_node("review-change") {
2791            WorkflowNode::Leaf(leaf) => leaf,
2792            _ => unreachable!("leaf helper returns a leaf"),
2793        };
2794        profiled_leaf.profile = Some("reviewer".to_string());
2795        let workflow = workflow_spec(vec![
2796            WorkflowNode::Leaf(profiled_leaf),
2797            leaf_node("scan-readme"),
2798        ]);
2799
2800        let execution = MockWorkflowExecutor::new()
2801            .run(&workflow)
2802            .expect("mock workflow should run");
2803
2804        assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
2805        assert_eq!(
2806            execution.leaf_results[0].profile.as_deref(),
2807            Some("reviewer")
2808        );
2809        assert_eq!(execution.leaf_results[1].profile, None);
2810    }
2811
2812    #[test]
2813    fn leaf_profile_token_rule_rejects_invalid_names() {
2814        for bad in ["", "has space", "quote\"y", "role=reviewer", "back`tick"] {
2815            let mut leaf = match leaf_node("scan") {
2816                WorkflowNode::Leaf(leaf) => leaf,
2817                _ => unreachable!("leaf helper returns a leaf"),
2818            };
2819            leaf.profile = Some(bad.to_string());
2820            let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]);
2821
2822            let err = MockWorkflowExecutor::new()
2823                .run(&workflow)
2824                .expect_err("invalid profile token should fail validation");
2825
2826            assert!(
2827                matches!(&err, WorkflowExecutionError::InvalidLeafProfile { profile, .. } if profile == bad),
2828                "profile `{bad}` should be rejected, got {err:?}"
2829            );
2830        }
2831
2832        let mut leaf = match leaf_node("scan") {
2833            WorkflowNode::Leaf(leaf) => leaf,
2834            _ => unreachable!("leaf helper returns a leaf"),
2835        };
2836        leaf.profile = Some("reviewer".to_string());
2837        let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]);
2838        MockWorkflowExecutor::new()
2839            .run(&workflow)
2840            .expect("valid profile token should pass validation");
2841    }
2842
2843    #[test]
2844    fn mock_executor_aggregates_leaf_usage() {
2845        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2846            id: "discover".to_string(),
2847            description: None,
2848            parallel: true,
2849            budget: BudgetSpec::default(),
2850            permissions: PermissionSpec::default(),
2851            model_policy: ModelPolicy::default(),
2852            children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")],
2853        })]);
2854
2855        let mut executor = MockWorkflowExecutor::new()
2856            .with_leaf_outcome(
2857                "scan-readme",
2858                MockLeafOutcome::succeeded("readme ok").with_usage(WorkflowUsage {
2859                    input_tokens: 100,
2860                    output_tokens: 25,
2861                    cost_microusd: 500,
2862                }),
2863            )
2864            .with_leaf_outcome(
2865                "scan-tests",
2866                MockLeafOutcome::succeeded("tests ok").with_usage(WorkflowUsage {
2867                    input_tokens: 50,
2868                    output_tokens: 10,
2869                    cost_microusd: 250,
2870                }),
2871            );
2872
2873        let execution = executor.run(&workflow).expect("mock workflow should run");
2874
2875        assert_eq!(
2876            execution.usage,
2877            WorkflowUsage {
2878                input_tokens: 150,
2879                output_tokens: 35,
2880                cost_microusd: 750,
2881            }
2882        );
2883        assert_eq!(execution.usage.total_tokens(), 185);
2884        assert_eq!(execution.branch_results[0].usage, execution.usage);
2885        assert_eq!(
2886            execution
2887                .leaf_results
2888                .iter()
2889                .map(|result| result.usage.cost_microusd)
2890                .collect::<Vec<_>>(),
2891            vec![500, 250]
2892        );
2893    }
2894
2895    #[test]
2896    fn mock_executor_aggregates_memo_usage() {
2897        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2898            id: "cache-branches".to_string(),
2899            description: None,
2900            parallel: true,
2901            budget: BudgetSpec::default(),
2902            permissions: PermissionSpec::default(),
2903            model_policy: ModelPolicy::default(),
2904            children: vec![leaf_node("rlm-hit"), leaf_node("rlm-miss")],
2905        })]);
2906
2907        let mut executor = MockWorkflowExecutor::new()
2908            .with_leaf_outcome(
2909                "rlm-hit",
2910                MockLeafOutcome::succeeded("memo hit").with_memo_usage(WorkflowMemoUsage {
2911                    armh_hits: 1,
2912                    armh_misses: 0,
2913                    armh_saved_estimated_tokens: 4096,
2914                    provider_prompt_cache_hits: 1,
2915                    provider_prompt_cache_misses: 0,
2916                }),
2917            )
2918            .with_leaf_outcome(
2919                "rlm-miss",
2920                MockLeafOutcome::succeeded("memo miss").with_memo_usage(WorkflowMemoUsage {
2921                    armh_hits: 0,
2922                    armh_misses: 1,
2923                    armh_saved_estimated_tokens: 0,
2924                    provider_prompt_cache_hits: 0,
2925                    provider_prompt_cache_misses: 1,
2926                }),
2927            );
2928
2929        let execution = executor.run(&workflow).expect("mock workflow should run");
2930
2931        assert_eq!(
2932            execution.memo_usage,
2933            WorkflowMemoUsage {
2934                armh_hits: 1,
2935                armh_misses: 1,
2936                armh_saved_estimated_tokens: 4096,
2937                provider_prompt_cache_hits: 1,
2938                provider_prompt_cache_misses: 1,
2939            }
2940        );
2941        assert_eq!(execution.branch_results[0].memo_usage, execution.memo_usage);
2942        assert_eq!(
2943            execution
2944                .leaf_results
2945                .iter()
2946                .map(|result| (result.memo_usage.armh_hits, result.memo_usage.armh_misses))
2947                .collect::<Vec<_>>(),
2948            vec![(1, 0), (0, 1)]
2949        );
2950    }
2951
2952    #[test]
2953    fn mock_executor_marks_cancelled_before_leaf() {
2954        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2955            id: "discover".to_string(),
2956            description: None,
2957            parallel: true,
2958            budget: BudgetSpec::default(),
2959            permissions: PermissionSpec::default(),
2960            model_policy: ModelPolicy::default(),
2961            children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")],
2962        })]);
2963
2964        let mut executor = MockWorkflowExecutor::new().with_cancelled();
2965        let execution = executor.run(&workflow).expect("mock workflow should run");
2966
2967        assert_eq!(execution.status, WorkflowRunStatus::Cancelled);
2968        assert_eq!(execution.leaf_results.len(), 1);
2969        assert_eq!(
2970            execution.leaf_results[0].status,
2971            WorkflowRunStatus::Cancelled
2972        );
2973        assert_eq!(
2974            execution.branch_results[0].status,
2975            WorkflowRunStatus::Cancelled
2976        );
2977        assert_eq!(
2978            control_result(&execution, "discover").status,
2979            WorkflowRunStatus::Cancelled
2980        );
2981    }
2982
2983    #[test]
2984    fn mock_executor_stops_when_global_leaf_budget_is_exhausted() {
2985        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
2986            id: "discover".to_string(),
2987            description: None,
2988            parallel: true,
2989            budget: BudgetSpec::default(),
2990            permissions: PermissionSpec::default(),
2991            model_policy: ModelPolicy::default(),
2992            children: vec![
2993                leaf_node("scan-readme"),
2994                leaf_node("scan-config"),
2995                leaf_node("scan-tests"),
2996            ],
2997        })]);
2998
2999        let mut executor = MockWorkflowExecutor::new().with_max_leaf_steps(1);
3000        let execution = executor.run(&workflow).expect("mock workflow should run");
3001
3002        assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3003        assert_eq!(
3004            execution
3005                .leaf_results
3006                .iter()
3007                .map(|result| (result.leaf_id.as_str(), result.status))
3008                .collect::<Vec<_>>(),
3009            vec![
3010                ("scan-readme", WorkflowRunStatus::Succeeded),
3011                ("scan-config", WorkflowRunStatus::BudgetExceeded)
3012            ]
3013        );
3014        assert_eq!(
3015            execution.branch_results[0].status,
3016            WorkflowRunStatus::BudgetExceeded
3017        );
3018    }
3019
3020    #[test]
3021    fn mock_executor_honors_zero_step_leaf_budget() {
3022        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3023            id: "verify".to_string(),
3024            description: None,
3025            parallel: false,
3026            budget: BudgetSpec::default(),
3027            permissions: PermissionSpec::default(),
3028            model_policy: ModelPolicy::default(),
3029            children: vec![
3030                leaf_node_with_budget(
3031                    "run-tests",
3032                    BudgetSpec {
3033                        max_steps: Some(0),
3034                        timeout_secs: None,
3035                        max_parallel: None,
3036                        max_tokens: None,
3037                    },
3038                ),
3039                leaf_node("summarize"),
3040            ],
3041        })]);
3042
3043        let mut executor = MockWorkflowExecutor::new();
3044        let execution = executor.run(&workflow).expect("mock workflow should run");
3045
3046        assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3047        assert_eq!(execution.leaf_results.len(), 1);
3048        assert_eq!(
3049            execution.leaf_results[0].status,
3050            WorkflowRunStatus::BudgetExceeded
3051        );
3052        assert!(
3053            execution.leaf_results[0]
3054                .output
3055                .as_deref()
3056                .unwrap_or_default()
3057                .contains("budget exhausted")
3058        );
3059    }
3060
3061    #[test]
3062    fn mock_executor_stops_when_global_token_budget_is_exhausted() {
3063        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3064            id: "discover".to_string(),
3065            description: None,
3066            parallel: true,
3067            budget: BudgetSpec::default(),
3068            permissions: PermissionSpec::default(),
3069            model_policy: ModelPolicy::default(),
3070            children: vec![
3071                leaf_node("scan-readme"),
3072                leaf_node("scan-config"),
3073                leaf_node("scan-tests"),
3074            ],
3075        })]);
3076
3077        // First leaf uses 600 tokens (300 in + 300 out); after the second leaf
3078        // (500 tokens) the running total is 1100, exceeding the 1000-token
3079        // global cap, so the third leaf hits the exhausted budget and halts the
3080        // run.
3081        let mut executor = MockWorkflowExecutor::new()
3082            .with_max_leaf_tokens(1000)
3083            .with_leaf_outcome(
3084                "scan-readme",
3085                MockLeafOutcome::succeeded("readme done").with_usage(WorkflowUsage {
3086                    input_tokens: 300,
3087                    output_tokens: 300,
3088                    cost_microusd: 0,
3089                }),
3090            )
3091            .with_leaf_outcome(
3092                "scan-config",
3093                MockLeafOutcome::succeeded("config done").with_usage(WorkflowUsage {
3094                    input_tokens: 250,
3095                    output_tokens: 250,
3096                    cost_microusd: 0,
3097                }),
3098            );
3099        let execution = executor.run(&workflow).expect("mock workflow should run");
3100
3101        assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3102        // Leaves 1+2 consume 1100 tokens, exhausting the 1000-token global cap.
3103        // The third leaf is attempted, sees the budget already exceeded, and is
3104        // recorded as BudgetExceeded — the same boundary-leaf behaviour used by
3105        // step budgets (max_leaf_steps). The budget outcome carries no tokens,
3106        // so total usage stays at 1100.
3107        assert_eq!(execution.leaf_results.len(), 3);
3108        assert_eq!(
3109            execution.leaf_results[0].status,
3110            WorkflowRunStatus::Succeeded
3111        );
3112        assert_eq!(
3113            execution.leaf_results[1].status,
3114            WorkflowRunStatus::Succeeded
3115        );
3116        assert_eq!(
3117            execution.leaf_results[2].status,
3118            WorkflowRunStatus::BudgetExceeded
3119        );
3120        assert_eq!(execution.usage.total_tokens(), 1100);
3121    }
3122
3123    #[test]
3124    fn mock_executor_honors_zero_token_leaf_budget() {
3125        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3126            id: "verify".to_string(),
3127            description: None,
3128            parallel: false,
3129            budget: BudgetSpec::default(),
3130            permissions: PermissionSpec::default(),
3131            model_policy: ModelPolicy::default(),
3132            children: vec![
3133                leaf_node_with_budget(
3134                    "run-tests",
3135                    BudgetSpec {
3136                        max_steps: None,
3137                        timeout_secs: None,
3138                        max_parallel: None,
3139                        max_tokens: Some(0),
3140                    },
3141                ),
3142                leaf_node("summarize"),
3143            ],
3144        })]);
3145
3146        let mut executor = MockWorkflowExecutor::new();
3147        let execution = executor.run(&workflow).expect("mock workflow should run");
3148
3149        assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3150        assert_eq!(execution.leaf_results.len(), 1);
3151        assert_eq!(
3152            execution.leaf_results[0].status,
3153            WorkflowRunStatus::BudgetExceeded
3154        );
3155        assert!(
3156            execution.leaf_results[0]
3157                .output
3158                .as_deref()
3159                .unwrap_or_default()
3160                .contains("token budget exhausted")
3161        );
3162    }
3163
3164    #[test]
3165    fn mock_executor_honors_per_leaf_token_cap() {
3166        let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec {
3167            id: "review".to_string(),
3168            description: None,
3169            parallel: false,
3170            budget: BudgetSpec::default(),
3171            permissions: PermissionSpec::default(),
3172            model_policy: ModelPolicy::default(),
3173            children: vec![
3174                leaf_node_with_budget(
3175                    "expensive-scan",
3176                    BudgetSpec {
3177                        max_steps: None,
3178                        timeout_secs: None,
3179                        max_parallel: None,
3180                        max_tokens: Some(500),
3181                    },
3182                ),
3183                leaf_node("summarize"),
3184            ],
3185        })]);
3186
3187        // The leaf outcome uses 800 tokens which exceeds the per-leaf cap of 500.
3188        let mut executor = MockWorkflowExecutor::new().with_leaf_outcome(
3189            "expensive-scan",
3190            MockLeafOutcome::succeeded("scan done").with_usage(WorkflowUsage {
3191                input_tokens: 500,
3192                output_tokens: 300,
3193                cost_microusd: 0,
3194            }),
3195        );
3196        let execution = executor.run(&workflow).expect("mock workflow should run");
3197
3198        assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded);
3199        assert_eq!(execution.leaf_results.len(), 1);
3200        assert_eq!(
3201            execution.leaf_results[0].status,
3202            WorkflowRunStatus::BudgetExceeded
3203        );
3204        assert!(
3205            execution.leaf_results[0]
3206                .output
3207                .as_deref()
3208                .unwrap_or_default()
3209                .contains("token budget exhausted")
3210        );
3211    }
3212
3213    #[test]
3214    fn budget_spec_serializes_max_tokens() {
3215        let budget = BudgetSpec {
3216            max_steps: Some(10),
3217            timeout_secs: Some(600),
3218            max_parallel: Some(4),
3219            max_tokens: Some(50_000),
3220        };
3221        let json = serde_json::to_string(&budget).expect("serialize budget");
3222        let parsed: BudgetSpec = serde_json::from_str(&json).expect("parse budget");
3223        assert_eq!(parsed, budget);
3224        assert!(json.contains("\"max_tokens\":50000"));
3225
3226        // Default (all None) round-trips without the field present.
3227        let default_json =
3228            serde_json::to_string(&BudgetSpec::default()).expect("serialize default");
3229        let parsed_default: BudgetSpec =
3230            serde_json::from_str(&default_json).expect("parse default budget");
3231        assert_eq!(parsed_default, BudgetSpec::default());
3232        assert!(parsed_default.max_tokens.is_none());
3233    }
3234
3235    #[test]
3236    fn loop_until_stops_on_pass() {
3237        let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec {
3238            id: "verify".to_string(),
3239            condition: "verification passed".to_string(),
3240            max_iterations: Some(5),
3241            children: vec![leaf_node("run-check")],
3242        })]);
3243
3244        let mut executor =
3245            MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]);
3246        let execution = executor.run(&workflow).expect("loop should run");
3247
3248        assert_eq!(execution.status, WorkflowRunStatus::Succeeded);
3249        assert_eq!(execution.leaf_results.len(), 3);
3250        assert_eq!(
3251            control_result(&execution, "verify").summary.as_deref(),
3252            Some("loop_until iterations=3")
3253        );
3254    }
3255
3256    #[test]
3257    fn loop_until_honors_max_iters() {
3258        let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec {
3259            id: "verify".to_string(),
3260            condition: "verification passed".to_string(),
3261            max_iterations: Some(2),
3262            children: vec![leaf_node("run-check")],
3263        })]);
3264
3265        let mut executor =
3266            MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]);
3267        let execution = executor.run(&workflow).expect("loop should run");
3268
3269        assert_eq!(execution.status, WorkflowRunStatus::Failed);
3270        assert_eq!(execution.leaf_results.len(), 2);
3271        assert_eq!(
3272            control_result(&execution, "verify").summary.as_deref(),
3273            Some("loop_until iterations=2")
3274        );
3275    }
3276
3277    #[test]
3278    fn cond_uses_logged_predicate_result() {
3279        let workflow = workflow_spec(vec![WorkflowNode::Cond(CondSpec {
3280            id: "should-fix".to_string(),
3281            condition: "finding requires a patch".to_string(),
3282            then_nodes: vec![leaf_node("patch")],
3283            else_nodes: vec![leaf_node("report-only")],
3284        })]);
3285
3286        let mut executor =
3287            MockWorkflowExecutor::new().with_predicate_results("should-fix", vec![true]);
3288        let execution = executor.run(&workflow).expect("cond should run");
3289
3290        assert_eq!(
3291            execution
3292                .leaf_results
3293                .iter()
3294                .map(|result| result.leaf_id.as_str())
3295                .collect::<Vec<_>>(),
3296            vec!["patch"]
3297        );
3298        assert_eq!(
3299            control_result(&execution, "should-fix").summary.as_deref(),
3300            Some("predicate_result=true")
3301        );
3302    }
3303
3304    #[test]
3305    fn expand_respects_max_children() {
3306        let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
3307            id: "split".to_string(),
3308            source: "plan".to_string(),
3309            max_children: Some(2),
3310            template: None,
3311        })]);
3312
3313        let generated = vec![leaf_node("first"), leaf_node("second"), leaf_node("third")];
3314        let mut executor = MockWorkflowExecutor::new().with_generated_nodes("split", generated);
3315        let execution = executor.run(&workflow).expect("expand should run");
3316
3317        assert_eq!(
3318            execution
3319                .leaf_results
3320                .iter()
3321                .map(|result| result.leaf_id.as_str())
3322                .collect::<Vec<_>>(),
3323            vec!["first", "second"]
3324        );
3325        assert_eq!(
3326            control_result(&execution, "split").selected_children,
3327            vec!["first", "second"]
3328        );
3329    }
3330
3331    #[test]
3332    fn expand_generated_nodes_validate_before_run() {
3333        let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec {
3334            id: "split".to_string(),
3335            source: "plan".to_string(),
3336            max_children: None,
3337            template: None,
3338        })]);
3339
3340        let mut executor = MockWorkflowExecutor::new()
3341            .with_generated_nodes("split", vec![invalid_leaf_node("bad")]);
3342        let err = executor
3343            .run(&workflow)
3344            .expect_err("invalid generated leaf should fail before execution");
3345
3346        assert_eq!(
3347            err,
3348            WorkflowExecutionError::EmptyLeafPrompt {
3349                leaf: "bad".to_string()
3350            }
3351        );
3352    }
3353
3354    #[test]
3355    fn workflow_spec_rejects_unknown_leaf_dependency() {
3356        let mut summarize = leaf_node("summarize");
3357        let WorkflowNode::Leaf(spec) = &mut summarize else {
3358            panic!("expected leaf");
3359        };
3360        spec.depends_on_results = vec!["missing-scan".to_string()];
3361        let workflow = workflow_spec(vec![summarize]);
3362
3363        let mut executor = MockWorkflowExecutor::new();
3364        let err = executor
3365            .run(&workflow)
3366            .expect_err("unknown leaf dependency should fail before execution");
3367
3368        assert_eq!(
3369            err,
3370            WorkflowExecutionError::UnknownNodeReference {
3371                node: "summarize".to_string(),
3372                field: "depends_on_results",
3373                reference: "missing-scan".to_string(),
3374            }
3375        );
3376    }
3377
3378    #[test]
3379    fn workflow_spec_rejects_unknown_reduce_input() {
3380        let workflow = workflow_spec(vec![
3381            leaf_node("scan"),
3382            WorkflowNode::Reduce(ReduceSpec {
3383                id: "summarize".to_string(),
3384                inputs: vec!["scan".to_string(), "missing-review".to_string()],
3385                prompt: "Summarize safe fixes".to_string(),
3386                model_policy: ModelPolicy::default(),
3387            }),
3388        ]);
3389
3390        let mut executor = MockWorkflowExecutor::new();
3391        let err = executor
3392            .run(&workflow)
3393            .expect_err("unknown reduce input should fail before execution");
3394
3395        assert_eq!(
3396            err,
3397            WorkflowExecutionError::UnknownNodeReference {
3398                node: "summarize".to_string(),
3399                field: "inputs",
3400                reference: "missing-review".to_string(),
3401            }
3402        );
3403    }
3404
3405    #[test]
3406    fn workflow_spec_rejects_unknown_teacher_candidate() {
3407        let workflow = workflow_spec(vec![
3408            leaf_node("candidate-a"),
3409            WorkflowNode::TeacherReview(TeacherReviewSpec {
3410                id: "teacher-review".to_string(),
3411                candidates: vec!["candidate-a".to_string(), "candidate-b".to_string()],
3412                promotion_policy: PromotionPolicy::default(),
3413            }),
3414        ]);
3415
3416        let mut executor = MockWorkflowExecutor::new();
3417        let err = executor
3418            .run(&workflow)
3419            .expect_err("unknown teacher candidate should fail before execution");
3420
3421        assert_eq!(
3422            err,
3423            WorkflowExecutionError::UnknownNodeReference {
3424                node: "teacher-review".to_string(),
3425                field: "candidates",
3426                reference: "candidate-b".to_string(),
3427            }
3428        );
3429    }
3430
3431    #[test]
3432    fn teacher_candidate_serialization() {
3433        let candidate = TeacherCandidate {
3434            candidate_id: "teacher-review:branch-a".to_string(),
3435            kind: TeacherCandidateKind::WorkflowRecipe,
3436            status: TeacherCandidateStatus::Proposed,
3437            source_node_id: "branch-a".to_string(),
3438            source_branch_id: Some("branch-a".to_string()),
3439            summary: "Winning branch found a reusable workflow recipe.".to_string(),
3440            evidence: vec![
3441                "status=Succeeded".to_string(),
3442                "tokens=42, cost_microusd=7".to_string(),
3443            ],
3444            replay_results: vec![StudentReplayResult {
3445                trace_id: "trace-a".to_string(),
3446                candidate_id: "teacher-review:branch-a".to_string(),
3447                baseline: StudentReplayMetrics {
3448                    score: 70,
3449                    cost_microusd: 10,
3450                },
3451                candidate: StudentReplayMetrics {
3452                    score: 74,
3453                    cost_microusd: 12,
3454                },
3455                required_tests: vec![StudentReplayTestResult {
3456                    name: "cargo test -p codewhale-workflow".to_string(),
3457                    passed: true,
3458                }],
3459                policy_violations: Vec::new(),
3460                stale: false,
3461                notes: Some("offline replay improved the constrained student".to_string()),
3462            }],
3463        };
3464
3465        let json = serde_json::to_string(&candidate).expect("serialize teacher candidate");
3466
3467        assert!(json.contains("\"kind\":\"workflow_recipe\""));
3468        assert!(json.contains("\"status\":\"proposed\""));
3469        assert!(json.contains("\"replay_results\""));
3470        let parsed: TeacherCandidate =
3471            serde_json::from_str(&json).expect("parse teacher candidate");
3472        assert_eq!(parsed, candidate);
3473    }
3474
3475    #[test]
3476    fn teacher_review_produces_candidate_from_trace() {
3477        let review = TeacherReviewSpec {
3478            id: "teacher-review".to_string(),
3479            candidates: vec!["winning-branch".to_string()],
3480            promotion_policy: PromotionPolicy::default(),
3481        };
3482        let execution = WorkflowExecution {
3483            branch_results: vec![BranchResult {
3484                branch_id: "winning-branch".to_string(),
3485                task_id: "winning-branch".to_string(),
3486                status: WorkflowRunStatus::Succeeded,
3487                usage: WorkflowUsage {
3488                    input_tokens: 30,
3489                    output_tokens: 12,
3490                    cost_microusd: 7,
3491                },
3492                memo_usage: WorkflowMemoUsage::default(),
3493                artifacts: vec!["trace://branches/winning-branch".to_string()],
3494                notes: Some("branch produced a minimal verified patch".to_string()),
3495            }],
3496            ..WorkflowExecution::default()
3497        };
3498
3499        let report = TeacherReviewReport::from_execution(&review, &execution);
3500
3501        assert_eq!(report.review_node_id, "teacher-review");
3502        assert_eq!(report.candidates.len(), 1);
3503        assert_eq!(
3504            report.candidates[0].kind,
3505            TeacherCandidateKind::WorkflowRecipe
3506        );
3507        assert_eq!(
3508            report.candidates[0].status,
3509            TeacherCandidateStatus::Proposed
3510        );
3511        assert!(
3512            report.candidates[0]
3513                .evidence
3514                .iter()
3515                .any(|line| line.contains("tokens=42"))
3516        );
3517    }
3518
3519    #[test]
3520    fn failed_leaf_becomes_regression_test_candidate() {
3521        let review = TeacherReviewSpec {
3522            id: "teacher-review".to_string(),
3523            candidates: vec!["verify-failure".to_string()],
3524            promotion_policy: PromotionPolicy::default(),
3525        };
3526        let execution = WorkflowExecution {
3527            leaf_results: vec![LeafResult {
3528                leaf_id: "verify-failure".to_string(),
3529                task_id: "verify-failure".to_string(),
3530                profile: None,
3531                status: WorkflowRunStatus::Failed,
3532                usage: WorkflowUsage::default(),
3533                memo_usage: WorkflowMemoUsage::default(),
3534                output: Some("cargo test failed with a replay mismatch".to_string()),
3535                artifacts: Vec::new(),
3536                schema_error: None,
3537            }],
3538            ..WorkflowExecution::default()
3539        };
3540
3541        let candidates = teacher_candidates_from_execution(&review, &execution);
3542
3543        assert_eq!(candidates.len(), 1);
3544        assert_eq!(candidates[0].kind, TeacherCandidateKind::RegressionTest);
3545        assert_eq!(candidates[0].status, TeacherCandidateStatus::Proposed);
3546        assert!(
3547            candidates[0]
3548                .evidence
3549                .iter()
3550                .any(|line| { line.contains("cargo test failed with a replay mismatch") })
3551        );
3552    }
3553
3554    #[test]
3555    fn student_replay_promotes_only_on_delta() {
3556        let gate = PromotionGate {
3557            min_score_delta: 3,
3558            max_cost_delta_microusd: Some(25),
3559            ..PromotionGate::default()
3560        };
3561        let replay = StudentReplayResult {
3562            trace_id: "trace-a".to_string(),
3563            candidate_id: "teacher-review:branch-a".to_string(),
3564            baseline: StudentReplayMetrics {
3565                score: 80,
3566                cost_microusd: 100,
3567            },
3568            candidate: StudentReplayMetrics {
3569                score: 84,
3570                cost_microusd: 120,
3571            },
3572            required_tests: vec![StudentReplayTestResult {
3573                name: "workflow replay".to_string(),
3574                passed: true,
3575            }],
3576            policy_violations: Vec::new(),
3577            stale: false,
3578            notes: None,
3579        };
3580
3581        let promoted = gate.evaluate_replay("teacher-review:branch-a", &replay);
3582        assert!(promoted.promoted());
3583        assert_eq!(promoted.status, TeacherCandidateStatus::Promoted);
3584        assert_eq!(promoted.score_delta, 4);
3585
3586        let weak_replay = StudentReplayResult {
3587            candidate: StudentReplayMetrics {
3588                score: 82,
3589                cost_microusd: 120,
3590            },
3591            ..replay
3592        };
3593        let rejected = gate.evaluate_replay("teacher-review:branch-a", &weak_replay);
3594        assert!(!rejected.promoted());
3595        assert_eq!(rejected.status, TeacherCandidateStatus::Rejected);
3596        assert!(
3597            rejected
3598                .reasons
3599                .iter()
3600                .any(|reason| reason.contains("below required 3"))
3601        );
3602    }
3603
3604    #[test]
3605    fn promotion_gate_rejects_stale_policy_cost_and_failed_tests() {
3606        let gate = PromotionGate {
3607            min_score_delta: 1,
3608            max_cost_delta_microusd: Some(10),
3609            ..PromotionGate::default()
3610        };
3611        let replay = StudentReplayResult {
3612            trace_id: "trace-a".to_string(),
3613            candidate_id: "teacher-review:branch-a".to_string(),
3614            baseline: StudentReplayMetrics {
3615                score: 70,
3616                cost_microusd: 10,
3617            },
3618            candidate: StudentReplayMetrics {
3619                score: 90,
3620                cost_microusd: 30,
3621            },
3622            required_tests: vec![StudentReplayTestResult {
3623                name: "required regression".to_string(),
3624                passed: false,
3625            }],
3626            policy_violations: vec!["writes outside file scope".to_string()],
3627            stale: true,
3628            notes: None,
3629        };
3630
3631        let decision = gate.evaluate_replay("teacher-review:branch-a", &replay);
3632
3633        assert_eq!(decision.status, TeacherCandidateStatus::Rejected);
3634        assert!(
3635            decision
3636                .reasons
3637                .iter()
3638                .any(|reason| { reason.contains("cost delta 20 exceeds allowed 10") })
3639        );
3640        assert!(
3641            decision
3642                .reasons
3643                .iter()
3644                .any(|reason| { reason.contains("required test `required regression` failed") })
3645        );
3646        assert!(
3647            decision
3648                .reasons
3649                .iter()
3650                .any(|reason| { reason.contains("policy violation: writes outside file scope") })
3651        );
3652        assert!(
3653            decision
3654                .reasons
3655                .iter()
3656                .any(|reason| { reason.contains("student replay result is stale") })
3657        );
3658    }
3659
3660    #[test]
3661    fn promotion_gate_requires_recorded_replay_before_candidate_promotion() {
3662        let candidate = TeacherCandidate {
3663            candidate_id: "teacher-review:branch-a".to_string(),
3664            kind: TeacherCandidateKind::WorkflowRecipe,
3665            status: TeacherCandidateStatus::Proposed,
3666            source_node_id: "branch-a".to_string(),
3667            source_branch_id: Some("branch-a".to_string()),
3668            summary: "candidate waits for replay".to_string(),
3669            evidence: Vec::new(),
3670            replay_results: Vec::new(),
3671        };
3672
3673        let decision = PromotionGate::default().evaluate_candidate(&candidate);
3674
3675        assert_eq!(decision.status, TeacherCandidateStatus::Rejected);
3676        assert_eq!(
3677            decision.reasons,
3678            vec!["no student replay result recorded".to_string()]
3679        );
3680    }
3681
3682    #[test]
3683    fn tournament_selects_passing_minimal_branch() {
3684        let tournament = BranchTournament { min_score: 60 };
3685        let candidates = vec![
3686            candidate(
3687                "expensive-pass",
3688                WorkflowRunStatus::Succeeded,
3689                90,
3690                90,
3691                "quality",
3692            ),
3693            candidate("failed-cheap", WorkflowRunStatus::Failed, 100, 1, "broken"),
3694            candidate(
3695                "cheap-pass",
3696                WorkflowRunStatus::Succeeded,
3697                70,
3698                10,
3699                "minimal",
3700            ),
3701            candidate("too-low", WorkflowRunStatus::Succeeded, 40, 2, "weak"),
3702        ];
3703
3704        let selected = tournament
3705            .select(&candidates)
3706            .expect("one passing branch should be selected");
3707
3708        assert_eq!(selected.branch_id, "cheap-pass");
3709    }
3710
3711    #[test]
3712    fn pareto_frontier_keeps_diverse_candidates() {
3713        let frontier = ParetoFrontier { max_items: 4 };
3714        let candidates = vec![
3715            candidate("quality", WorkflowRunStatus::Succeeded, 95, 100, "quality"),
3716            candidate("minimal", WorkflowRunStatus::Succeeded, 70, 10, "small"),
3717            candidate("dominated", WorkflowRunStatus::Succeeded, 60, 40, "middle"),
3718            candidate("failed", WorkflowRunStatus::Failed, 100, 1, "broken"),
3719        ];
3720
3721        let selected = frontier.select(&candidates);
3722
3723        assert_eq!(
3724            selected
3725                .iter()
3726                .map(|candidate| candidate.branch_id.as_str())
3727                .collect::<Vec<_>>(),
3728            vec!["quality", "minimal"]
3729        );
3730        assert_eq!(
3731            selected
3732                .iter()
3733                .filter_map(|candidate| candidate.diversity_key.as_deref())
3734                .collect::<Vec<_>>(),
3735            vec!["quality", "small"]
3736        );
3737    }
3738}