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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))]
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user