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) <noreply@anthropic.com>
This commit is contained in:
Hunter B
2026-06-13 08:15:14 -07:00
parent f6867e65bd
commit 38ce04790a
4 changed files with 661 additions and 0 deletions
+547
View File
@@ -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<T> = std::result::Result<T, JavascriptWorkflowError>;
#[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<WorkflowSpec> {
compile_js_like_workflow(identifier, source)
}
pub fn compile_typescript_workflow(
identifier: &str,
source: &str,
) -> JavascriptWorkflowResult<WorkflowSpec> {
compile_js_like_workflow(identifier, source)
}
fn compile_js_like_workflow(
_identifier: &str,
source: &str,
) -> JavascriptWorkflowResult<WorkflowSpec> {
reject_unsupported_constructs(source)?;
let object = extract_workflow_object(source)?;
let authored = serde_json::from_str::<JsWorkflowSpec>(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<char> = 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<String>,
goal: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
budget: BudgetSpec,
#[serde(default)]
permissions: PermissionSpec,
#[serde(default)]
model_policy: ModelPolicy,
#[serde(default)]
promotion_policy: PromotionPolicy,
#[serde(default)]
nodes: Vec<JsWorkflowNode>,
}
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<String>,
#[serde(default = "default_true")]
parallel: bool,
#[serde(default)]
budget: BudgetSpec,
#[serde(default)]
permissions: PermissionSpec,
#[serde(default)]
model_policy: ModelPolicy,
#[serde(default)]
children: Vec<JsWorkflowNode>,
}
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<JsWorkflowNode>,
}
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<u32>,
#[serde(default)]
children: Vec<JsWorkflowNode>,
}
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<JsWorkflowNode>,
#[serde(default)]
else_nodes: Vec<JsWorkflowNode>,
}
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<usize>,
#[serde(default)]
template: Option<Box<JsWorkflowNode>>,
}
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);
}
}
+5
View File
@@ -4,6 +4,7 @@
//! exposure, worktree application, replay, and model execution are layered on //! exposure, worktree application, replay, and model execution are layered on
//! top only after their cancellation and evidence semantics are proven. //! top only after their cancellation and evidence semantics are proven.
mod js_authoring;
mod model_policy; mod model_policy;
mod replay; mod replay;
#[cfg(not(target_env = "ohos"))] #[cfg(not(target_env = "ohos"))]
@@ -15,6 +16,10 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
pub use js_authoring::{
JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow,
compile_typescript_workflow,
};
pub use model_policy::*; pub use model_policy::*;
pub use replay::*; pub use replay::*;
#[cfg(not(target_env = "ohos"))] #[cfg(not(target_env = "ohos"))]
+59
View File
@@ -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`.
+50
View File
@@ -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."
}
}
]
});