From 38ce04790a05f4a6995562ebdf38afb4ed81ee35 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Sat, 13 Jun 2026 08:15:14 -0700 Subject: [PATCH] feat(whaleflow): add declarative JS workflow authoring Adds a compile-only JavaScript/TypeScript authoring path that extracts a JSON-compatible workflow({...}) object, lowers it to the existing WorkflowSpec IR, and runs the Rust validation gate before execution. Includes a branch/reduce .workflow.js example and a short authoring design note comparing YAML/JSON, Starlark, JavaScript, and TypeScript. The compiler rejects effectful JavaScript constructs instead of executing workflow source as a second runtime. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/whaleflow/src/js_authoring.rs | 547 +++++++++++++++++++++++++++ crates/whaleflow/src/lib.rs | 5 + docs/WHALEFLOW_AUTHORING.md | 59 +++ workflows/issue_audit.workflow.js | 50 +++ 4 files changed, 661 insertions(+) create mode 100644 crates/whaleflow/src/js_authoring.rs create mode 100644 docs/WHALEFLOW_AUTHORING.md create mode 100644 workflows/issue_audit.workflow.js diff --git a/crates/whaleflow/src/js_authoring.rs b/crates/whaleflow/src/js_authoring.rs new file mode 100644 index 00000000..a68a2143 --- /dev/null +++ b/crates/whaleflow/src/js_authoring.rs @@ -0,0 +1,547 @@ +use serde::Deserialize; +use thiserror::Error; + +use crate::{ + BranchSpec, BudgetSpec, CondSpec, ExpandSpec, LeafSpec, LoopUntilSpec, ModelPolicy, + PermissionSpec, PromotionPolicy, ReduceSpec, SequenceSpec, TeacherReviewSpec, WorkflowNode, + WorkflowSpec, validate_workflow_nodes, +}; + +pub type JavascriptWorkflowResult = std::result::Result; + +#[derive(Debug, Error)] +pub enum JavascriptWorkflowError { + #[error("workflow source contains unsupported construct `{construct}`")] + UnsupportedConstruct { construct: &'static str }, + #[error("workflow source did not call workflow({{...}})")] + MissingWorkflowCall, + #[error("workflow({{...}}) object could not be extracted: {0}")] + InvalidWorkflowObject(String), + #[error("invalid workflow JSON object: {0}")] + InvalidJson(serde_json::Error), + #[error("invalid workflow node: {0}")] + InvalidNode(String), +} + +pub fn compile_javascript_workflow( + identifier: &str, + source: &str, +) -> JavascriptWorkflowResult { + compile_js_like_workflow(identifier, source) +} + +pub fn compile_typescript_workflow( + identifier: &str, + source: &str, +) -> JavascriptWorkflowResult { + compile_js_like_workflow(identifier, source) +} + +fn compile_js_like_workflow( + _identifier: &str, + source: &str, +) -> JavascriptWorkflowResult { + reject_unsupported_constructs(source)?; + let object = extract_workflow_object(source)?; + let authored = serde_json::from_str::(object) + .map_err(JavascriptWorkflowError::InvalidJson)?; + let workflow = authored.into_workflow(); + if workflow.goal.trim().is_empty() { + return Err(JavascriptWorkflowError::InvalidNode( + "workflow goal cannot be empty".to_string(), + )); + } + validate_workflow_nodes(&workflow.nodes) + .map_err(|error| JavascriptWorkflowError::InvalidNode(error.to_string()))?; + Ok(workflow) +} + +fn reject_unsupported_constructs(source: &str) -> JavascriptWorkflowResult<()> { + for (needle, construct) in [ + ("import ", "import"), + ("import(", "dynamic import"), + ("require(", "require"), + ("fetch(", "fetch"), + ("XMLHttpRequest", "XMLHttpRequest"), + ("WebSocket", "WebSocket"), + ("process.", "process"), + ("Deno.", "Deno"), + ("Bun.", "Bun"), + ("child_process", "child_process"), + ("exec(", "exec"), + ("spawn(", "spawn"), + ("open(", "open"), + ("readFile", "readFile"), + ("writeFile", "writeFile"), + ("async ", "async"), + ("await ", "await"), + ("eval(", "eval"), + ("new Function", "Function"), + ] { + if source.contains(needle) { + return Err(JavascriptWorkflowError::UnsupportedConstruct { construct }); + } + } + Ok(()) +} + +fn extract_workflow_object(source: &str) -> JavascriptWorkflowResult<&str> { + let workflow_pos = source + .find("workflow") + .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?; + let open_paren_rel = source[workflow_pos..] + .find('(') + .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?; + let open_paren = workflow_pos + open_paren_rel; + let object_start = source[open_paren + 1..] + .char_indices() + .find_map(|(idx, ch)| { + if ch.is_whitespace() { + None + } else { + Some((open_paren + 1 + idx, ch)) + } + }) + .ok_or(JavascriptWorkflowError::MissingWorkflowCall)?; + if object_start.1 != '{' { + return Err(JavascriptWorkflowError::InvalidWorkflowObject( + "workflow(...) must receive a JSON-compatible object literal".to_string(), + )); + } + + let mut depth = 0usize; + let mut in_string: Option = None; + let mut escape = false; + for (idx, ch) in source[object_start.0..].char_indices() { + let absolute = object_start.0 + idx; + if let Some(quote) = in_string { + if escape { + escape = false; + } else if ch == '\\' { + escape = true; + } else if ch == quote { + in_string = None; + } + continue; + } + + match ch { + '"' | '\'' | '`' => in_string = Some(ch), + '{' => depth += 1, + '}' => { + depth = depth.checked_sub(1).ok_or_else(|| { + JavascriptWorkflowError::InvalidWorkflowObject( + "unbalanced closing brace".to_string(), + ) + })?; + if depth == 0 { + return Ok(&source[object_start.0..=absolute]); + } + } + _ => {} + } + } + + Err(JavascriptWorkflowError::InvalidWorkflowObject( + "missing closing brace for workflow object".to_string(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsWorkflowSpec { + #[serde(default)] + id: Option, + goal: String, + #[serde(default)] + description: Option, + #[serde(default)] + budget: BudgetSpec, + #[serde(default)] + permissions: PermissionSpec, + #[serde(default)] + model_policy: ModelPolicy, + #[serde(default)] + promotion_policy: PromotionPolicy, + #[serde(default)] + nodes: Vec, +} + +impl JsWorkflowSpec { + fn into_workflow(self) -> WorkflowSpec { + WorkflowSpec { + id: self.id, + goal: self.goal, + description: self.description, + budget: self.budget, + permissions: self.permissions, + model_policy: self.model_policy, + promotion_policy: self.promotion_policy, + nodes: self + .nodes + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum JsWorkflowNode { + Raw(WorkflowNode), + Agent(JsAgentNode), + Branch(JsBranchNode), + Sequence(JsSequenceNode), + Reduce(JsReduceNode), + TeacherReview(JsTeacherReviewNode), + LoopUntil(JsLoopUntilNode), + Cond(JsCondNode), + Expand(JsExpandNode), +} + +impl JsWorkflowNode { + fn into_node(self) -> WorkflowNode { + match self { + Self::Raw(node) => node, + Self::Agent(node) => WorkflowNode::Leaf(node.agent), + Self::Branch(node) => WorkflowNode::BranchSet(node.branch.into_branch()), + Self::Sequence(node) => WorkflowNode::Sequence(node.sequence.into_sequence()), + Self::Reduce(node) => WorkflowNode::Reduce(node.reduce), + Self::TeacherReview(node) => WorkflowNode::TeacherReview(node.teacher_review), + Self::LoopUntil(node) => WorkflowNode::LoopUntil(node.loop_until.into_loop_until()), + Self::Cond(node) => WorkflowNode::Cond(node.cond.into_cond()), + Self::Expand(node) => WorkflowNode::Expand(node.expand.into_expand()), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsAgentNode { + agent: LeafSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsBranchNode { + branch: JsBranchSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsBranchSpec { + id: String, + #[serde(default)] + description: Option, + #[serde(default = "default_true")] + parallel: bool, + #[serde(default)] + budget: BudgetSpec, + #[serde(default)] + permissions: PermissionSpec, + #[serde(default)] + model_policy: ModelPolicy, + #[serde(default)] + children: Vec, +} + +impl JsBranchSpec { + fn into_branch(self) -> BranchSpec { + BranchSpec { + id: self.id, + description: self.description, + parallel: self.parallel, + budget: self.budget, + permissions: self.permissions, + model_policy: self.model_policy, + children: self + .children + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsSequenceNode { + sequence: JsSequenceSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsSequenceSpec { + id: String, + #[serde(default)] + children: Vec, +} + +impl JsSequenceSpec { + fn into_sequence(self) -> SequenceSpec { + SequenceSpec { + id: self.id, + children: self + .children + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsReduceNode { + reduce: ReduceSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsTeacherReviewNode { + teacher_review: TeacherReviewSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsLoopUntilNode { + loop_until: JsLoopUntilSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsLoopUntilSpec { + id: String, + condition: String, + #[serde(default)] + max_iterations: Option, + #[serde(default)] + children: Vec, +} + +impl JsLoopUntilSpec { + fn into_loop_until(self) -> LoopUntilSpec { + LoopUntilSpec { + id: self.id, + condition: self.condition, + max_iterations: self.max_iterations, + children: self + .children + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsCondNode { + cond: JsCondSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsCondSpec { + id: String, + condition: String, + #[serde(default)] + then_nodes: Vec, + #[serde(default)] + else_nodes: Vec, +} + +impl JsCondSpec { + fn into_cond(self) -> CondSpec { + CondSpec { + id: self.id, + condition: self.condition, + then_nodes: self + .then_nodes + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + else_nodes: self + .else_nodes + .into_iter() + .map(JsWorkflowNode::into_node) + .collect(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsExpandNode { + expand: JsExpandSpec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsExpandSpec { + id: String, + source: String, + #[serde(default)] + max_children: Option, + #[serde(default)] + template: Option>, +} + +impl JsExpandSpec { + fn into_expand(self) -> ExpandSpec { + ExpandSpec { + id: self.id, + source: self.source, + max_children: self.max_children, + template: self.template.map(|node| Box::new(node.into_node())), + } + } +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AgentType, WorkflowReplayExecutor}; + + #[test] + fn javascript_workflow_compiles_branch_reduce_to_ir() { + let source = r#" +export default workflow({ + "id": "js-audit", + "goal": "Audit a change with parallel agents", + "nodes": [ + { + "branch": { + "id": "parallel-audit", + "children": [ + { + "agent": { + "id": "docs-audit", + "prompt": "Inspect docs for missing updates", + "agent_type": "review", + "file_scope": ["docs"] + } + }, + { + "agent": { + "id": "tests-audit", + "prompt": "Inspect targeted tests", + "agent_type": "verifier", + "budget": { "max_steps": 4 } + } + } + ] + } + }, + { + "reduce": { + "id": "synthesize", + "inputs": ["docs-audit", "tests-audit"], + "prompt": "Merge the branch findings" + } + } + ] +}); +"#; + + let workflow = + compile_javascript_workflow("audit.workflow.js", source).expect("compile JS workflow"); + + assert_eq!(workflow.id.as_deref(), Some("js-audit")); + assert_eq!(workflow.nodes.len(), 2); + let WorkflowNode::BranchSet(branch) = &workflow.nodes[0] else { + panic!("first node should be a branch"); + }; + assert!(branch.parallel); + assert_eq!(branch.children.len(), 2); + let WorkflowNode::Leaf(leaf) = &branch.children[1] else { + panic!("second branch child should be a leaf"); + }; + assert_eq!(leaf.agent_type, AgentType::Verifier); + assert_eq!(leaf.budget.max_steps, Some(4)); + assert!(matches!(workflow.nodes[1], WorkflowNode::Reduce(_))); + } + + #[test] + fn typescript_workflow_allows_satisfies_suffix_without_executing_js() { + let source = r#" +export default workflow({ + "goal": "TS authored workflow", + "nodes": [ + { "agent": { "id": "scan", "prompt": "scan safely" } } + ] +} satisfies WorkflowSpec); +"#; + + let workflow = + compile_typescript_workflow("scan.workflow.ts", source).expect("compile TS workflow"); + + assert_eq!(workflow.goal, "TS authored workflow"); + assert_eq!(workflow.nodes.len(), 1); + } + + #[test] + fn javascript_workflow_rejects_runtime_effects() { + let source = r#" +import fs from "fs"; +workflow({ "goal": "bad", "nodes": [] }); +"#; + + let err = compile_javascript_workflow("bad.workflow.js", source) + .expect_err("imports must be rejected"); + + assert!(matches!( + err, + JavascriptWorkflowError::UnsupportedConstruct { + construct: "import" + } + )); + } + + #[test] + fn javascript_workflow_rejects_unknown_result_reference() { + let source = r#" +workflow({ + "goal": "bad dependency", + "nodes": [ + { + "agent": { + "id": "scan", + "prompt": "scan safely", + "depends_on_results": ["missing"] + } + } + ] +}); +"#; + + let err = compile_javascript_workflow("bad-reference.workflow.js", source) + .expect_err("validation must reject unknown result references"); + + assert!(matches!(err, JavascriptWorkflowError::InvalidNode(_))); + assert!(err.to_string().contains("missing")); + } + + #[test] + fn javascript_example_compiles_and_replays_with_mock_trace() { + let source = include_str!("../../../workflows/issue_audit.workflow.js"); + let workflow = + compile_javascript_workflow("issue_audit.workflow.js", source).expect("compile"); + let trace = crate::WorkflowReplayTrace { + trace_id: "empty".to_string(), + leaf_records: Vec::new(), + control_records: Vec::new(), + }; + + let replayed = WorkflowReplayExecutor::new(trace) + .run(&workflow) + .expect("replay executor should accept validated JS IR"); + + assert_eq!(replayed.status, crate::WorkflowRunStatus::ReplayDiverged); + } +} diff --git a/crates/whaleflow/src/lib.rs b/crates/whaleflow/src/lib.rs index 155946a7..76f37f40 100644 --- a/crates/whaleflow/src/lib.rs +++ b/crates/whaleflow/src/lib.rs @@ -4,6 +4,7 @@ //! exposure, worktree application, replay, and model execution are layered on //! top only after their cancellation and evidence semantics are proven. +mod js_authoring; mod model_policy; mod replay; #[cfg(not(target_env = "ohos"))] @@ -15,6 +16,10 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub use js_authoring::{ + JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow, + compile_typescript_workflow, +}; pub use model_policy::*; pub use replay::*; #[cfg(not(target_env = "ohos"))] diff --git a/docs/WHALEFLOW_AUTHORING.md b/docs/WHALEFLOW_AUTHORING.md new file mode 100644 index 00000000..04bd2483 --- /dev/null +++ b/docs/WHALEFLOW_AUTHORING.md @@ -0,0 +1,59 @@ +# WhaleFlow Authoring + +WhaleFlow has one runtime boundary: authored workflow source lowers to typed +Rust `WorkflowSpec`, Rust validates the IR, and the scheduler/headless worker +runtime executes leaves. Authoring languages do not get hidden authority to own +files, shell, network, providers, cancellation, or TUI state. + +## Language Choice + +| Surface | Strength | Tradeoff | v0.8.60 stance | +|---|---|---|---| +| YAML / JSON IR | Simple, reviewable, no runtime | Verbose for generated workflows | Keep as interchange/debug format | +| Starlark | Existing safe evaluator and helper functions | Less familiar to most JS/TS developers and coding agents | Keep supported | +| JavaScript | Familiar object syntax and easy agent generation | Unsafe if executed as a general runtime | First-class authoring through declarative compile-only subset | +| TypeScript | Best editor/types story for workflow SDK | Needs stripping/typechecking if full TS is supported | Same compile-only subset for now; richer SDK later | + +The default high-capability path is TypeScript/JavaScript authoring, but only as +a compile step. The v0.8.60 compiler accepts a JSON-compatible object inside +`workflow({...})` from `.workflow.js` or `.workflow.ts`, lowers it to +`WorkflowSpec`, and runs the same Rust validation gate used by Starlark. + +## Contract + +Accepted source shape: + +```js +export default workflow({ + "id": "issue-audit-js", + "goal": "Audit an issue fix with parallel agents", + "nodes": [ + { + "branch": { + "id": "parallel-audit", + "children": [ + { "agent": { "id": "code-audit", "prompt": "Review code", "agent_type": "review" } }, + { "agent": { "id": "test-audit", "prompt": "Review tests", "agent_type": "verifier" } } + ] + } + }, + { "reduce": { "id": "summary", "inputs": ["code-audit", "test-audit"], "prompt": "Summarize" } } + ] +}); +``` + +Supported node wrappers: `agent`, `branch`, `sequence`, `reduce`, +`teacher_review`, `loop_until`, `cond`, and `expand`. Raw `WorkflowNode` JSON IR +with `kind` / `spec` also remains valid. + +The compiler rejects effectful constructs such as `import`, `require`, `fetch`, +`process`, `Deno`, `Bun`, `child_process`, file reads/writes, `eval`, `async`, +and `await`. This is intentionally stricter than JavaScript: workflow source is +a familiar declaration format, not a second execution runtime. + +## Verification + +- `cargo test -p codewhale-whaleflow --locked javascript` +- `cargo test -p codewhale-whaleflow --locked starlark` + +Current example: `workflows/issue_audit.workflow.js`. diff --git a/workflows/issue_audit.workflow.js b/workflows/issue_audit.workflow.js new file mode 100644 index 00000000..f3230842 --- /dev/null +++ b/workflows/issue_audit.workflow.js @@ -0,0 +1,50 @@ +export default workflow({ + "id": "issue-audit-js", + "goal": "Audit an issue fix with parallel specialist agents, then synthesize a release note", + "description": "Declarative JavaScript authoring example lowered to typed WhaleFlow IR without executing JS.", + "nodes": [ + { + "branch": { + "id": "parallel-audit", + "parallel": true, + "children": [ + { + "agent": { + "id": "code-audit", + "prompt": "Inspect the implementation for correctness and regression risk.", + "agent_type": "review", + "mode": "read_only", + "file_scope": ["crates"] + } + }, + { + "agent": { + "id": "test-audit", + "prompt": "Inspect targeted tests and identify missing verification.", + "agent_type": "verifier", + "mode": "read_only", + "file_scope": ["crates", "tests"], + "budget": { "max_steps": 4, "timeout_secs": 300 } + } + }, + { + "agent": { + "id": "docs-audit", + "prompt": "Check whether docs or release notes should mention the change.", + "agent_type": "review", + "mode": "read_only", + "file_scope": ["docs"] + } + } + ] + } + }, + { + "reduce": { + "id": "synthesize-release-risk", + "inputs": ["code-audit", "test-audit", "docs-audit"], + "prompt": "Combine specialist findings into a release-ready risk summary." + } + } + ] +});