diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8ccb367..539a42c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, verification, risks, and handoff notes through the transcript card, Plan 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 managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/Cargo.lock b/Cargo.lock index 8004b9670..5ff23ee2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1028,6 +1028,15 @@ dependencies = [ name = "codewhale-tui-core" version = "0.8.53" +[[package]] +name = "codewhale-whaleflow" +version = "0.8.53" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7dae34322..8efbeb9a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/tools", "crates/tui", "crates/tui-core", + "crates/whaleflow", ] default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index ee8ccb367..539a42c4a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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, verification, risks, and handoff notes through the transcript card, Plan 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 managed session. The endpoint preserves thread title/model/mode/workspace metadata, maps missing threads to 404, and returns 409 instead of snapshotting diff --git a/crates/whaleflow/Cargo.toml b/crates/whaleflow/Cargo.toml new file mode 100644 index 000000000..c41d8e360 --- /dev/null +++ b/crates/whaleflow/Cargo.toml @@ -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 diff --git a/crates/whaleflow/src/lib.rs b/crates/whaleflow/src/lib.rs new file mode 100644 index 000000000..543824b28 --- /dev/null +++ b/crates/whaleflow/src/lib.rs @@ -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, + #[serde(default)] + pub phases: Vec, +} + +impl WorkflowConfig { + pub fn validate(&self) -> Result<(), WorkflowValidationError> { + WorkflowPlan::from_config(self).map(|_| ()) + } + + pub fn compile(&self) -> Result { + WorkflowPlan::from_config(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkflowPlan { + goal: String, + max_concurrent: u8, + phases: Vec, +} + +impl WorkflowPlan { + pub fn from_config(config: &WorkflowConfig) -> Result { + 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 { + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Phase { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub parallel: bool, + #[serde(default)] + pub on_failure: FailurePolicy, + #[serde(default)] + pub tasks: Vec, +} + +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, + #[serde(default)] + pub depends_on_results: Vec, + #[serde(default)] + pub max_steps: Option, + #[serde(default)] + pub timeout_secs: Option, +} + +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, +) -> Result, 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, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + ordered: &mut Vec, +) -> 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) -> WorkflowConfig { + WorkflowConfig { + goal: "cache-change".to_string(), + max_concurrent: 4, + description: None, + phases, + } + } + + fn phase(name: &str, depends_on: &[&str], tasks: Vec) -> 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!["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!["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); + } +} diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 69de0dab9..d5f36b7f3 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -25,6 +25,7 @@ Current packaging note: - `codewhale-core` - `codewhale-app-server` - `codewhale-tui-core` + - `codewhale-whaleflow` ## Version Coordination @@ -119,20 +120,22 @@ configured. `main` and letting `auto-tag.yml` create the tag — see the npm wrapper release section below for the `RELEASE_TAG_PAT` requirement). 4. Publish crates in this order with `./scripts/release/publish-crates.sh publish`: - - `codewhale-secrets` - - `codewhale-config` + - `codewhale-mcp` - `codewhale-protocol` + - `codewhale-release` + - `codewhale-secrets` - `codewhale-state` - - `codewhale-agent` + - `codewhale-tui-core` + - `codewhale-whaleflow` - `codewhale-execpolicy` - `codewhale-hooks` - - `codewhale-mcp` - `codewhale-tools` + - `codewhale-config` + - `codewhale-agent` + - `codewhale-tui` - `codewhale-core` - `codewhale-app-server` - - `codewhale-tui-core` - `codewhale-cli` - - `codewhale-tui` 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. diff --git a/scripts/release/crates.sh b/scripts/release/crates.sh index ff3828327..8936dcf56 100755 --- a/scripts/release/crates.sh +++ b/scripts/release/crates.sh @@ -8,6 +8,7 @@ release_crates=( codewhale-secrets codewhale-state codewhale-tui-core + codewhale-whaleflow codewhale-execpolicy codewhale-hooks codewhale-tools