feat(whaleflow): add typed workflow foundation
Harvested from PR #2482 by @AdityaVG13, preserving the typed WhaleFlow config and deterministic planner direction without exposing the runtime workflow_run tool yet. Co-authored-by: AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
grounded objectives, context, sources, critical files, constraints,
|
grounded objectives, context, sources, critical files, constraints,
|
||||||
verification, risks, and handoff notes through the transcript card, Plan
|
verification, risks, and handoff notes through the transcript card, Plan
|
||||||
confirmation prompt, `/relay`, fork-state, and saved-session replay.
|
confirmation prompt, `/relay`, fork-state, and saved-session replay.
|
||||||
|
- Added the first `codewhale-whaleflow` foundation crate with typed workflow
|
||||||
|
config/IR validation and deterministic phase ordering tests. This preserves
|
||||||
|
the WhaleFlow direction from #2482/#2486 without exposing a runtime
|
||||||
|
`workflow_run` tool until cancellation, replay, and worktree semantics are
|
||||||
|
release-safe. Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking
|
||||||
|
direction.
|
||||||
- Added `POST /v1/sessions` for runtime clients to save a completed thread as a
|
- Added `POST /v1/sessions` for runtime clients to save a completed thread as a
|
||||||
managed session. The endpoint preserves thread title/model/mode/workspace
|
managed session. The endpoint preserves thread title/model/mode/workspace
|
||||||
metadata, maps missing threads to 404, and returns 409 instead of snapshotting
|
metadata, maps missing threads to 404, and returns 409 instead of snapshotting
|
||||||
|
|||||||
Generated
+9
@@ -1028,6 +1028,15 @@ dependencies = [
|
|||||||
name = "codewhale-tui-core"
|
name = "codewhale-tui-core"
|
||||||
version = "0.8.53"
|
version = "0.8.53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codewhale-whaleflow"
|
||||||
|
version = "0.8.53"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ members = [
|
|||||||
"crates/tools",
|
"crates/tools",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/tui-core",
|
"crates/tui-core",
|
||||||
|
"crates/whaleflow",
|
||||||
]
|
]
|
||||||
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
grounded objectives, context, sources, critical files, constraints,
|
grounded objectives, context, sources, critical files, constraints,
|
||||||
verification, risks, and handoff notes through the transcript card, Plan
|
verification, risks, and handoff notes through the transcript card, Plan
|
||||||
confirmation prompt, `/relay`, fork-state, and saved-session replay.
|
confirmation prompt, `/relay`, fork-state, and saved-session replay.
|
||||||
|
- Added the first `codewhale-whaleflow` foundation crate with typed workflow
|
||||||
|
config/IR validation and deterministic phase ordering tests. This preserves
|
||||||
|
the WhaleFlow direction from #2482/#2486 without exposing a runtime
|
||||||
|
`workflow_run` tool until cancellation, replay, and worktree semantics are
|
||||||
|
release-safe. Thanks @AdityaVG13 for the WhaleFlow draft and cost-tracking
|
||||||
|
direction.
|
||||||
- Added `POST /v1/sessions` for runtime clients to save a completed thread as a
|
- Added `POST /v1/sessions` for runtime clients to save a completed thread as a
|
||||||
managed session. The endpoint preserves thread title/model/mode/workspace
|
managed session. The endpoint preserves thread title/model/mode/workspace
|
||||||
metadata, maps missing threads to 404, and returns 409 instead of snapshotting
|
metadata, maps missing threads to 404, and returns 409 instead of snapshotting
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "codewhale-whaleflow"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Typed WhaleFlow workflow IR and validation for CodeWhale"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
//! Typed WhaleFlow workflow configuration and validation.
|
||||||
|
//!
|
||||||
|
//! This crate deliberately stops at the Rust-owned IR boundary. Runtime tool
|
||||||
|
//! exposure, worktree application, replay, and model execution are layered on
|
||||||
|
//! top only after their cancellation and evidence semantics are proven.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkflowConfig {
|
||||||
|
pub goal: String,
|
||||||
|
#[serde(default = "default_max_concurrent")]
|
||||||
|
pub max_concurrent: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phases: Vec<Phase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkflowConfig {
|
||||||
|
pub fn validate(&self) -> Result<(), WorkflowValidationError> {
|
||||||
|
WorkflowPlan::from_config(self).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compile(&self) -> Result<WorkflowPlan, WorkflowValidationError> {
|
||||||
|
WorkflowPlan::from_config(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkflowPlan {
|
||||||
|
goal: String,
|
||||||
|
max_concurrent: u8,
|
||||||
|
phases: Vec<PhasePlan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkflowPlan {
|
||||||
|
pub fn from_config(config: &WorkflowConfig) -> Result<Self, WorkflowValidationError> {
|
||||||
|
validate_non_empty("workflow goal", &config.goal)?;
|
||||||
|
if !(1..=20).contains(&config.max_concurrent) {
|
||||||
|
return Err(WorkflowValidationError::InvalidMaxConcurrent {
|
||||||
|
value: config.max_concurrent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if config.phases.is_empty() {
|
||||||
|
return Err(WorkflowValidationError::EmptyWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut phase_indices = BTreeMap::new();
|
||||||
|
let mut all_tasks = BTreeMap::new();
|
||||||
|
let mut task_phase = BTreeMap::new();
|
||||||
|
|
||||||
|
for (phase_index, phase) in config.phases.iter().enumerate() {
|
||||||
|
validate_non_empty("phase name", &phase.name)?;
|
||||||
|
if phase.tasks.is_empty() {
|
||||||
|
return Err(WorkflowValidationError::EmptyPhase {
|
||||||
|
phase: phase.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if phase_indices
|
||||||
|
.insert(phase.name.clone(), phase_index)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(WorkflowValidationError::DuplicatePhase {
|
||||||
|
phase: phase.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for task in &phase.tasks {
|
||||||
|
validate_non_empty("task id", &task.id)?;
|
||||||
|
validate_non_empty("task prompt", &task.prompt)?;
|
||||||
|
if all_tasks.insert(task.id.clone(), task).is_some() {
|
||||||
|
return Err(WorkflowValidationError::DuplicateTask {
|
||||||
|
task: task.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
task_phase.insert(task.id.clone(), phase.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for phase in &config.phases {
|
||||||
|
for dependency in &phase.depends_on {
|
||||||
|
if dependency == &phase.name || !phase_indices.contains_key(dependency) {
|
||||||
|
return Err(WorkflowValidationError::InvalidPhaseDependency {
|
||||||
|
phase: phase.name.clone(),
|
||||||
|
dependency: dependency.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_parallel_write_scope(phase)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ordered_phase_names = ordered_phases(config, &phase_indices)?;
|
||||||
|
let phase_order: BTreeMap<_, _> = ordered_phase_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, phase)| (phase.clone(), index))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for phase in &config.phases {
|
||||||
|
for task in &phase.tasks {
|
||||||
|
for dependency in &task.depends_on_results {
|
||||||
|
let Some(dependency_phase) = task_phase.get(dependency) else {
|
||||||
|
return Err(WorkflowValidationError::InvalidTaskResultDependency {
|
||||||
|
task: task.id.clone(),
|
||||||
|
dependency: dependency.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if phase_order[dependency_phase] >= phase_order[&phase.name] {
|
||||||
|
return Err(WorkflowValidationError::UnavailableTaskResultDependency {
|
||||||
|
task: task.id.clone(),
|
||||||
|
dependency: dependency.clone(),
|
||||||
|
dependency_phase: dependency_phase.clone(),
|
||||||
|
task_phase: phase.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let phases = ordered_phase_names
|
||||||
|
.iter()
|
||||||
|
.map(|phase_name| {
|
||||||
|
let phase = &config.phases[phase_indices[phase_name]];
|
||||||
|
PhasePlan {
|
||||||
|
name: phase.name.clone(),
|
||||||
|
parallel: phase.parallel,
|
||||||
|
on_failure: phase.on_failure,
|
||||||
|
tasks: phase.tasks.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
goal: config.goal.clone(),
|
||||||
|
max_concurrent: config.max_concurrent,
|
||||||
|
phases,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn goal(&self) -> &str {
|
||||||
|
&self.goal
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_concurrent(&self) -> u8 {
|
||||||
|
self.max_concurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn phases(&self) -> &[PhasePlan] {
|
||||||
|
&self.phases
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn phase_names(&self) -> impl Iterator<Item = &str> {
|
||||||
|
self.phases.iter().map(|phase| phase.name.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type WorkflowIr = WorkflowPlan;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PhasePlan {
|
||||||
|
pub name: String,
|
||||||
|
pub parallel: bool,
|
||||||
|
pub on_failure: FailurePolicy,
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Phase {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub depends_on: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parallel: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub on_failure: FailurePolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type WorkflowPhase = Phase;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FailurePolicy {
|
||||||
|
#[default]
|
||||||
|
SkipContinue,
|
||||||
|
Abort,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: String,
|
||||||
|
pub prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub agent_type: AgentType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: TaskMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub isolation: IsolationMode,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_scope: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub depends_on_results: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_steps: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type WorkflowTask = Task;
|
||||||
|
pub type WorkflowRole = AgentType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentType {
|
||||||
|
#[default]
|
||||||
|
General,
|
||||||
|
Explore,
|
||||||
|
Plan,
|
||||||
|
Review,
|
||||||
|
Implementer,
|
||||||
|
Verifier,
|
||||||
|
ToolAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TaskMode {
|
||||||
|
#[default]
|
||||||
|
ReadOnly,
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IsolationMode {
|
||||||
|
#[default]
|
||||||
|
Shared,
|
||||||
|
Worktree,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum WorkflowValidationError {
|
||||||
|
#[error("{field} must not be empty")]
|
||||||
|
EmptyField { field: &'static str },
|
||||||
|
#[error("workflow must contain at least one phase")]
|
||||||
|
EmptyWorkflow,
|
||||||
|
#[error("phase `{phase}` must contain at least one task")]
|
||||||
|
EmptyPhase { phase: String },
|
||||||
|
#[error("max_concurrent must be between 1 and 20, got {value}")]
|
||||||
|
InvalidMaxConcurrent { value: u8 },
|
||||||
|
#[error("duplicate workflow phase `{phase}`")]
|
||||||
|
DuplicatePhase { phase: String },
|
||||||
|
#[error("duplicate workflow task `{task}`")]
|
||||||
|
DuplicateTask { task: String },
|
||||||
|
#[error("phase `{phase}` has invalid dependency `{dependency}`")]
|
||||||
|
InvalidPhaseDependency { phase: String, dependency: String },
|
||||||
|
#[error("phase dependency cycle includes `{phase}`")]
|
||||||
|
PhaseDependencyCycle { phase: String },
|
||||||
|
#[error("task `{task}` has invalid result dependency `{dependency}`")]
|
||||||
|
InvalidTaskResultDependency { task: String, dependency: String },
|
||||||
|
#[error(
|
||||||
|
"task `{task}` depends on result `{dependency}` from unavailable phase `{dependency_phase}` while running in `{task_phase}`"
|
||||||
|
)]
|
||||||
|
UnavailableTaskResultDependency {
|
||||||
|
task: String,
|
||||||
|
dependency: String,
|
||||||
|
dependency_phase: String,
|
||||||
|
task_phase: String,
|
||||||
|
},
|
||||||
|
#[error("parallel read-write task `{task}` must declare a file_scope")]
|
||||||
|
MissingParallelWriteScope { task: String },
|
||||||
|
#[error("parallel read-write tasks `{left}` and `{right}` have overlapping file scopes")]
|
||||||
|
OverlappingParallelWriteScope { left: String, right: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_concurrent() -> u8 {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_non_empty(field: &'static str, value: &str) -> Result<(), WorkflowValidationError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(WorkflowValidationError::EmptyField { field });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ordered_phases(
|
||||||
|
config: &WorkflowConfig,
|
||||||
|
phase_indices: &BTreeMap<String, usize>,
|
||||||
|
) -> Result<Vec<String>, WorkflowValidationError> {
|
||||||
|
let mut visiting = BTreeSet::new();
|
||||||
|
let mut visited = BTreeSet::new();
|
||||||
|
let mut ordered = Vec::with_capacity(config.phases.len());
|
||||||
|
|
||||||
|
for phase in &config.phases {
|
||||||
|
visit_phase(
|
||||||
|
&phase.name,
|
||||||
|
config,
|
||||||
|
phase_indices,
|
||||||
|
&mut visiting,
|
||||||
|
&mut visited,
|
||||||
|
&mut ordered,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_phase(
|
||||||
|
phase_name: &str,
|
||||||
|
config: &WorkflowConfig,
|
||||||
|
phase_indices: &BTreeMap<String, usize>,
|
||||||
|
visiting: &mut BTreeSet<String>,
|
||||||
|
visited: &mut BTreeSet<String>,
|
||||||
|
ordered: &mut Vec<String>,
|
||||||
|
) -> Result<(), WorkflowValidationError> {
|
||||||
|
if visited.contains(phase_name) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !visiting.insert(phase_name.to_string()) {
|
||||||
|
return Err(WorkflowValidationError::PhaseDependencyCycle {
|
||||||
|
phase: phase_name.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase = &config.phases[phase_indices[phase_name]];
|
||||||
|
for dependency in &phase.depends_on {
|
||||||
|
visit_phase(
|
||||||
|
dependency,
|
||||||
|
config,
|
||||||
|
phase_indices,
|
||||||
|
visiting,
|
||||||
|
visited,
|
||||||
|
ordered,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.remove(phase_name);
|
||||||
|
visited.insert(phase_name.to_string());
|
||||||
|
ordered.push(phase_name.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_parallel_write_scope(phase: &Phase) -> Result<(), WorkflowValidationError> {
|
||||||
|
if !phase.parallel {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let write_tasks: Vec<_> = phase
|
||||||
|
.tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.mode == TaskMode::ReadWrite)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for task in &write_tasks {
|
||||||
|
if task.file_scope.is_empty() {
|
||||||
|
return Err(WorkflowValidationError::MissingParallelWriteScope {
|
||||||
|
task: task.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (left_index, left) in write_tasks.iter().enumerate() {
|
||||||
|
for right in write_tasks.iter().skip(left_index + 1) {
|
||||||
|
if scopes_overlap(&left.file_scope, &right.file_scope) {
|
||||||
|
return Err(WorkflowValidationError::OverlappingParallelWriteScope {
|
||||||
|
left: left.id.clone(),
|
||||||
|
right: right.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scopes_overlap(left: &[String], right: &[String]) -> bool {
|
||||||
|
left.iter().any(|left_scope| {
|
||||||
|
right
|
||||||
|
.iter()
|
||||||
|
.any(|right_scope| scope_overlaps(left_scope, right_scope))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_overlaps(left: &str, right: &str) -> bool {
|
||||||
|
let left = normalize_scope(left);
|
||||||
|
let right = normalize_scope(right);
|
||||||
|
|
||||||
|
if left == right || left == "." || right == "." {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if left.contains('*') || right.contains('*') {
|
||||||
|
return glob_prefix(&left) == glob_prefix(&right);
|
||||||
|
}
|
||||||
|
|
||||||
|
let left_path = Path::new(&left);
|
||||||
|
let right_path = Path::new(&right);
|
||||||
|
left_path.starts_with(right_path) || right_path.starts_with(left_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_scope(scope: &str) -> String {
|
||||||
|
let trimmed = scope.trim().trim_start_matches("./").trim_end_matches('/');
|
||||||
|
trimmed
|
||||||
|
.strip_suffix("/**")
|
||||||
|
.or_else(|| trimmed.strip_suffix("/*"))
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_prefix(scope: &str) -> String {
|
||||||
|
scope
|
||||||
|
.split('*')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(scope)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn task(id: &str) -> Task {
|
||||||
|
Task {
|
||||||
|
id: id.to_string(),
|
||||||
|
prompt: format!("run {id}"),
|
||||||
|
agent_type: AgentType::General,
|
||||||
|
mode: TaskMode::ReadOnly,
|
||||||
|
isolation: IsolationMode::Shared,
|
||||||
|
file_scope: Vec::new(),
|
||||||
|
depends_on_results: Vec::new(),
|
||||||
|
max_steps: None,
|
||||||
|
timeout_secs: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(phases: Vec<Phase>) -> WorkflowConfig {
|
||||||
|
WorkflowConfig {
|
||||||
|
goal: "cache-change".to_string(),
|
||||||
|
max_concurrent: 4,
|
||||||
|
description: None,
|
||||||
|
phases,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn phase(name: &str, depends_on: &[&str], tasks: Vec<Task>) -> Phase {
|
||||||
|
Phase {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: None,
|
||||||
|
depends_on: depends_on.iter().map(|value| value.to_string()).collect(),
|
||||||
|
parallel: false,
|
||||||
|
on_failure: FailurePolicy::SkipContinue,
|
||||||
|
tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_phases_preserve_declaration_order() {
|
||||||
|
let workflow = config(vec![
|
||||||
|
phase("discover", &[], vec![task("scan")]),
|
||||||
|
phase("report", &[], vec![task("summarize")]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let plan = workflow.compile().expect("workflow should compile");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.phase_names().collect::<Vec<_>>(),
|
||||||
|
vec!["discover", "report"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dependencies_override_declaration_order_deterministically() {
|
||||||
|
let workflow = config(vec![
|
||||||
|
phase("review", &["implement"], vec![task("review-results")]),
|
||||||
|
phase("discover", &[], vec![task("scan")]),
|
||||||
|
phase("implement", &["discover"], vec![task("patch")]),
|
||||||
|
phase("report", &["review"], vec![task("summarize")]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let plan = workflow.compile().expect("workflow should compile");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.phase_names().collect::<Vec<_>>(),
|
||||||
|
vec!["discover", "implement", "review", "report"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_workflow() {
|
||||||
|
let err = config(Vec::new())
|
||||||
|
.validate()
|
||||||
|
.expect_err("empty workflow should fail");
|
||||||
|
|
||||||
|
assert_eq!(err, WorkflowValidationError::EmptyWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_phase() {
|
||||||
|
let err = config(vec![phase("empty", &[], Vec::new())])
|
||||||
|
.validate()
|
||||||
|
.expect_err("empty phase should fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::EmptyPhase {
|
||||||
|
phase: "empty".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_max_concurrent() {
|
||||||
|
let mut workflow = config(vec![phase("discover", &[], vec![task("scan")])]);
|
||||||
|
workflow.max_concurrent = 0;
|
||||||
|
|
||||||
|
let err = workflow
|
||||||
|
.validate()
|
||||||
|
.expect_err("zero concurrency should fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::InvalidMaxConcurrent { value: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_phase_names() {
|
||||||
|
let err = config(vec![
|
||||||
|
phase("discover", &[], vec![task("scan")]),
|
||||||
|
phase("discover", &[], vec![task("scan-again")]),
|
||||||
|
])
|
||||||
|
.validate()
|
||||||
|
.expect_err("duplicate phase should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::DuplicatePhase { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_task_ids() {
|
||||||
|
let err = config(vec![
|
||||||
|
phase("discover", &[], vec![task("scan")]),
|
||||||
|
phase("report", &[], vec![task("scan")]),
|
||||||
|
])
|
||||||
|
.validate()
|
||||||
|
.expect_err("duplicate task should fail");
|
||||||
|
|
||||||
|
assert!(matches!(err, WorkflowValidationError::DuplicateTask { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_phase_dependency() {
|
||||||
|
let err = config(vec![phase("report", &["missing"], vec![task("summarize")])])
|
||||||
|
.validate()
|
||||||
|
.expect_err("unknown dependency should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::InvalidPhaseDependency { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_phase_dependency_cycles() {
|
||||||
|
let workflow = config(vec![
|
||||||
|
phase("a", &["b"], vec![task("a-task")]),
|
||||||
|
phase("b", &["a"], vec![task("b-task")]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let err = workflow.validate().expect_err("cycle should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::PhaseDependencyCycle { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_task_result_dependency_from_same_parallel_phase() {
|
||||||
|
let mut first = task("first");
|
||||||
|
first.depends_on_results.push("second".to_string());
|
||||||
|
let mut parallel = phase("parallel", &[], vec![first, task("second")]);
|
||||||
|
parallel.parallel = true;
|
||||||
|
|
||||||
|
let err = config(vec![parallel])
|
||||||
|
.validate()
|
||||||
|
.expect_err("same-phase result dependency should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::UnavailableTaskResultDependency { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_task_result_dependency_from_later_phase() {
|
||||||
|
let mut summarize = task("summarize");
|
||||||
|
summarize.depends_on_results.push("scan".to_string());
|
||||||
|
let workflow = config(vec![
|
||||||
|
phase("report", &[], vec![summarize]),
|
||||||
|
phase("discover", &[], vec![task("scan")]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let err = workflow
|
||||||
|
.validate()
|
||||||
|
.expect_err("later-phase result dependency should fail");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::UnavailableTaskResultDependency { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_task_result_dependency_from_earlier_phase() {
|
||||||
|
let upstream = phase("discover", &[], vec![task("scan")]);
|
||||||
|
let mut summarize = task("summarize");
|
||||||
|
summarize.depends_on_results.push("scan".to_string());
|
||||||
|
let downstream = phase("report", &["discover"], vec![summarize]);
|
||||||
|
|
||||||
|
config(vec![upstream, downstream])
|
||||||
|
.validate()
|
||||||
|
.expect("earlier-phase result should be available");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_parallel_read_write_without_file_scope() {
|
||||||
|
let mut write = task("write");
|
||||||
|
write.mode = TaskMode::ReadWrite;
|
||||||
|
let mut parallel = phase("parallel", &[], vec![write]);
|
||||||
|
parallel.parallel = true;
|
||||||
|
|
||||||
|
let err = config(vec![parallel])
|
||||||
|
.validate()
|
||||||
|
.expect_err("write task needs a scope");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::MissingParallelWriteScope { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_overlapping_parallel_write_scopes_with_path_boundaries() {
|
||||||
|
let mut left = task("auth");
|
||||||
|
left.mode = TaskMode::ReadWrite;
|
||||||
|
left.file_scope = vec!["src/auth/**".to_string()];
|
||||||
|
let mut right = task("auth-login");
|
||||||
|
right.mode = TaskMode::ReadWrite;
|
||||||
|
right.file_scope = vec!["src/auth/login.rs".to_string()];
|
||||||
|
let mut parallel = phase("parallel", &[], vec![left, right]);
|
||||||
|
parallel.parallel = true;
|
||||||
|
|
||||||
|
let err = config(vec![parallel])
|
||||||
|
.validate()
|
||||||
|
.expect_err("nested scopes should overlap");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
WorkflowValidationError::OverlappingParallelWriteScope { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_confuse_path_prefixes_for_overlapping_scopes() {
|
||||||
|
let mut left = task("auth");
|
||||||
|
left.mode = TaskMode::ReadWrite;
|
||||||
|
left.file_scope = vec!["src/auth/**".to_string()];
|
||||||
|
let mut right = task("auth-admin");
|
||||||
|
right.mode = TaskMode::ReadWrite;
|
||||||
|
right.file_scope = vec!["src/auth_admin/**".to_string()];
|
||||||
|
let mut parallel = phase("parallel", &[], vec![left, right]);
|
||||||
|
parallel.parallel = true;
|
||||||
|
|
||||||
|
config(vec![parallel])
|
||||||
|
.validate()
|
||||||
|
.expect("component boundary scopes should not overlap");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_roundtrip_keeps_snake_case_enum_names() {
|
||||||
|
let mut task = task("patch");
|
||||||
|
task.agent_type = AgentType::Implementer;
|
||||||
|
task.mode = TaskMode::ReadWrite;
|
||||||
|
task.isolation = IsolationMode::Worktree;
|
||||||
|
task.file_scope = vec!["src/auth/**".to_string()];
|
||||||
|
let mut parallel = phase("implement", &[], vec![task]);
|
||||||
|
parallel.parallel = true;
|
||||||
|
parallel.on_failure = FailurePolicy::Abort;
|
||||||
|
let workflow = config(vec![parallel]);
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&workflow).expect("serialize workflow");
|
||||||
|
|
||||||
|
assert!(json.contains("\"agent_type\":\"implementer\""));
|
||||||
|
assert!(json.contains("\"mode\":\"read_write\""));
|
||||||
|
assert!(json.contains("\"isolation\":\"worktree\""));
|
||||||
|
assert!(json.contains("\"on_failure\":\"abort\""));
|
||||||
|
|
||||||
|
let parsed: WorkflowConfig = serde_json::from_str(&json).expect("parse workflow");
|
||||||
|
assert_eq!(parsed, workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ Current packaging note:
|
|||||||
- `codewhale-core`
|
- `codewhale-core`
|
||||||
- `codewhale-app-server`
|
- `codewhale-app-server`
|
||||||
- `codewhale-tui-core`
|
- `codewhale-tui-core`
|
||||||
|
- `codewhale-whaleflow`
|
||||||
|
|
||||||
## Version Coordination
|
## Version Coordination
|
||||||
|
|
||||||
@@ -119,20 +120,22 @@ configured.
|
|||||||
`main` and letting `auto-tag.yml` create the tag — see the npm wrapper
|
`main` and letting `auto-tag.yml` create the tag — see the npm wrapper
|
||||||
release section below for the `RELEASE_TAG_PAT` requirement).
|
release section below for the `RELEASE_TAG_PAT` requirement).
|
||||||
4. Publish crates in this order with `./scripts/release/publish-crates.sh publish`:
|
4. Publish crates in this order with `./scripts/release/publish-crates.sh publish`:
|
||||||
- `codewhale-secrets`
|
- `codewhale-mcp`
|
||||||
- `codewhale-config`
|
|
||||||
- `codewhale-protocol`
|
- `codewhale-protocol`
|
||||||
|
- `codewhale-release`
|
||||||
|
- `codewhale-secrets`
|
||||||
- `codewhale-state`
|
- `codewhale-state`
|
||||||
- `codewhale-agent`
|
- `codewhale-tui-core`
|
||||||
|
- `codewhale-whaleflow`
|
||||||
- `codewhale-execpolicy`
|
- `codewhale-execpolicy`
|
||||||
- `codewhale-hooks`
|
- `codewhale-hooks`
|
||||||
- `codewhale-mcp`
|
|
||||||
- `codewhale-tools`
|
- `codewhale-tools`
|
||||||
|
- `codewhale-config`
|
||||||
|
- `codewhale-agent`
|
||||||
|
- `codewhale-tui`
|
||||||
- `codewhale-core`
|
- `codewhale-core`
|
||||||
- `codewhale-app-server`
|
- `codewhale-app-server`
|
||||||
- `codewhale-tui-core`
|
|
||||||
- `codewhale-cli`
|
- `codewhale-cli`
|
||||||
- `codewhale-tui`
|
|
||||||
5. Wait for each published crate version to appear on crates.io before publishing dependents.
|
5. Wait for each published crate version to appear on crates.io before publishing dependents.
|
||||||
|
|
||||||
The publish helper is idempotent for reruns: already-published crate versions are skipped.
|
The publish helper is idempotent for reruns: already-published crate versions are skipped.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ release_crates=(
|
|||||||
codewhale-secrets
|
codewhale-secrets
|
||||||
codewhale-state
|
codewhale-state
|
||||||
codewhale-tui-core
|
codewhale-tui-core
|
||||||
|
codewhale-whaleflow
|
||||||
codewhale-execpolicy
|
codewhale-execpolicy
|
||||||
codewhale-hooks
|
codewhale-hooks
|
||||||
codewhale-tools
|
codewhale-tools
|
||||||
|
|||||||
Reference in New Issue
Block a user