From abc6b12d6815edc4dda3ddf5f0572ecedcb670dd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 30 Mar 2026 15:34:07 +0000 Subject: [PATCH] feat: WAC workflow diagram visualization via WASM (#8604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: WAC workflow diagram visualization in script editor Add WASM-powered workflow diagram for WAC scripts in the script editor, inspired by Cloudflare's workflow diagrams approach. Parses WAC code client-side via WASM and renders an interactive DAG using @xyflow/svelte. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show WAC diagram on script detail page Show the workflow diagram below the run form on the script detail page for WAC scripts, matching how flows display their graph. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: handle try/catch and while loops in WAC diagram Instead of rejecting these patterns with validation errors, render them as graph nodes: - try/catch → Branch node with "try"/"catch" edge labels - while loops → LoopStart/LoopEnd with condition as iter_source Co-Authored-By: Claude Opus 4.6 (1M context) * chore: remove dead code from WAC parser and add pkg-wac to publish script Co-Authored-By: Claude Opus 4.6 (1M context) * chore: use published windmill-parser-wasm-wac@1.668.5 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: handle undefined language prop in WacDiagram usage Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve windmill-parser-wasm-wac from npm registry in lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * fix: create actual merge nodes for branch/try-catch convergence points Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.toml | 2 +- .../parsers/windmill-parser-wac/src/dag.rs | 4 + .../parsers/windmill-parser-wac/src/python.rs | 273 ++++++++++-- .../windmill-parser-wac/src/typescript.rs | 229 ++++++++-- .../windmill-parser-wac/src/validation.rs | 26 -- .../windmill-parser-wac/tests/python_tests.rs | 125 +++++- .../windmill-parser-wac/tests/ts_tests.rs | 192 ++++++++- .../windmill-parser-wasm/publish-pkgs.sh | 3 + frontend/package-lock.json | 56 +-- frontend/package.json | 1 + .../src/lib/components/ScriptBuilder.svelte | 2 +- .../src/lib/components/ScriptEditor.svelte | 398 +++++++++--------- .../lib/components/graph/WacDiagram.svelte | 109 +++++ .../graph/renderers/edges/WacEdge.svelte | 57 +++ .../renderers/nodes/WacControlNode.svelte | 57 +++ .../graph/renderers/nodes/WacStepNode.svelte | 39 ++ .../src/lib/components/graph/wacDagLayout.ts | 139 ++++++ .../src/lib/components/graph/wacToFlow.ts | 2 +- frontend/src/lib/infer.ts | 79 +++- .../scripts/get/[...hash]/+page.svelte | 289 +++++++------ 20 files changed, 1554 insertions(+), 528 deletions(-) create mode 100644 frontend/src/lib/components/graph/WacDiagram.svelte create mode 100644 frontend/src/lib/components/graph/renderers/edges/WacEdge.svelte create mode 100644 frontend/src/lib/components/graph/renderers/nodes/WacControlNode.svelte create mode 100644 frontend/src/lib/components/graph/renderers/nodes/WacStepNode.svelte create mode 100644 frontend/src/lib/components/graph/wacDagLayout.ts diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b7122f1f7e..86f896a9c9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -79,7 +79,7 @@ members = [ "./windmill-test-utils", "./windmill-api-integration-tests", ] -exclude = ["./windmill-duckdb-ffi-internal"] +exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] version = "1.668.5" diff --git a/backend/parsers/windmill-parser-wac/src/dag.rs b/backend/parsers/windmill-parser-wac/src/dag.rs index 662f5a06b6..f70bd6ac53 100644 --- a/backend/parsers/windmill-parser-wac/src/dag.rs +++ b/backend/parsers/windmill-parser-wac/src/dag.rs @@ -27,11 +27,15 @@ pub struct DagNode { #[serde(tag = "type")] pub enum DagNodeType { Step { name: String, script: String }, + InlineStep { name: String }, + Sleep { seconds: String }, + WaitForApproval, Branch { condition_source: String }, ParallelStart, ParallelEnd, LoopStart { iter_source: String }, LoopEnd, + Merge, Return, } diff --git a/backend/parsers/windmill-parser-wac/src/python.rs b/backend/parsers/windmill-parser-wac/src/python.rs index 74f0117376..f91067b711 100644 --- a/backend/parsers/windmill-parser-wac/src/python.rs +++ b/backend/parsers/windmill-parser-wac/src/python.rs @@ -37,7 +37,8 @@ impl LineIndex { /// Maps task function name → optional external path (from `@task(path="...")`) type TaskFunctions = HashMap>; -/// First pass: scan top-level `@task async def foo(...)` declarations. +/// First pass: scan top-level `@task async def foo(...)` declarations +/// and `foo = task_script("path")` / `foo = task_flow("path")` assignments. fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions { let mut tasks = HashMap::new(); for stmt in stmts { @@ -61,6 +62,30 @@ fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions { } } } + // foo = task_script("path") or foo = task_flow("path") + if let Stmt::Assign(assign) = stmt { + if let Expr::Call(call) = assign.value.as_ref() { + if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() { + if id.as_str() == "task_script" || id.as_str() == "task_flow" { + // Extract the path from the first positional argument + let path = call.args.first().and_then(|arg| { + if let Expr::Constant(c) = arg { + if let rustpython_parser::ast::Constant::Str(s) = &c.value { + return Some(s.to_string()); + } + } + None + }); + // Extract variable name from target + if let Some(Expr::Name(ExprName { id: var_name, .. })) = + assign.targets.first() + { + tasks.insert(var_name.to_string(), path); + } + } + } + } + } } tasks } @@ -88,8 +113,6 @@ struct WacWalker { node_counter: usize, line_index: LineIndex, task_functions: TaskFunctions, - in_try: bool, - in_while: bool, in_nested_func: bool, in_comprehension: bool, } @@ -103,8 +126,6 @@ impl WacWalker { node_counter: 0, line_index: LineIndex::new(source), task_functions, - in_try: false, - in_while: false, in_nested_func: false, in_comprehension: false, } @@ -292,6 +313,9 @@ impl WacWalker { if self.is_task_fn_call(expr) { return true; } + if Self::is_sdk_call(expr) { + return true; + } match expr { Expr::Await(ExprAwait { value, .. }) => self.expr_contains_step(value), Expr::Call(call) => { @@ -307,6 +331,17 @@ impl WacWalker { } } + /// Check if expr is a call to a known SDK function (step, sleep, wait_for_approval) + fn is_sdk_call(expr: &Expr) -> bool { + if let Expr::Call(call) = expr { + if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() { + let name = id.as_str(); + return name == "step" || name == "sleep" || name == "wait_for_approval"; + } + } + false + } + /// Walk a list of statements, returning (first_node_id, last_node_id) fn walk_body(&mut self, body: &[Stmt]) -> Option<(String, String)> { let mut first_id: Option = None; @@ -353,13 +388,17 @@ impl WacWalker { } fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> { - // await task_fn(...) + // await task_fn(...) / await step(...) / await sleep(...) / await wait_for_approval(...) if let Expr::Await(ExprAwait { value, .. }) = expr { // await task_fn(...) if let Expr::Call(call) = value.as_ref() { if self.is_task_fn_call(&Expr::Call(call.clone())) { return self.emit_step(call, expr); } + // Check for SDK-level calls: step(), sleep(), wait_for_approval() + if let Some(result) = self.try_emit_sdk_call(call, expr) { + return Some(result); + } } // await asyncio.gather(task_fn(...), task_fn(...), ...) if Self::is_asyncio_gather_call(value) { @@ -378,17 +417,69 @@ impl WacWalker { None } + /// Try to emit a node for SDK-level calls: step(), sleep(), wait_for_approval() + fn try_emit_sdk_call(&mut self, call: &ExprCall, expr: &Expr) -> Option<(String, String)> { + let callee_name = match call.func.as_ref() { + Expr::Name(ExprName { id, .. }) => Some(id.as_str()), + _ => None, + }?; + + let line = self.line_of_expr(expr); + + match callee_name { + "step" => { + // step("name", fn) — extract the name from the first string argument + let name = call + .args + .first() + .and_then(|arg| { + if let Expr::Constant(c) = arg { + if let rustpython_parser::ast::Constant::Str(s) = &c.value { + return Some(s.to_string()); + } + } + None + }) + .unwrap_or_else(|| "step".to_string()); + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::InlineStep { name: name.clone() }, + label: name, + line, + }); + Some((node_id.clone(), node_id)) + } + "sleep" => { + let seconds = call + .args + .first() + .map(|arg| Self::expr_to_source(arg)) + .unwrap_or_else(|| "?".to_string()); + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::Sleep { seconds: seconds.clone() }, + label: format!("sleep({seconds})"), + line, + }); + Some((node_id.clone(), node_id)) + } + "wait_for_approval" => { + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::WaitForApproval, + label: "wait_for_approval".to_string(), + line, + }); + Some((node_id.clone(), node_id)) + } + _ => None, + } + } + fn emit_step(&mut self, call: &ExprCall, expr: &Expr) -> Option<(String, String)> { - if self.in_try { - self.errors - .push(validation::error_step_in_try(self.line_of_expr(expr))); - return None; - } - if self.in_while { - self.errors - .push(validation::error_step_in_while(self.line_of_expr(expr))); - return None; - } if self.in_nested_func { self.errors.push(validation::error_step_in_nested_function( self.line_of_expr(expr), @@ -416,17 +507,6 @@ impl WacWalker { } fn emit_parallel(&mut self, gather_call: &ExprCall, expr: &Expr) -> Option<(String, String)> { - if self.in_try { - self.errors - .push(validation::error_step_in_try(self.line_of_expr(expr))); - return None; - } - if self.in_while { - self.errors - .push(validation::error_step_in_while(self.line_of_expr(expr))); - return None; - } - let line = self.line_of_expr(expr); let start_id = self.next_id(); let start_node_id = self.add_node(DagNode { @@ -491,8 +571,6 @@ impl WacWalker { line, }); - let merge_id = format!("{branch_id}_merge"); - let mut last_ids = Vec::new(); if let Some((true_first, true_last)) = self.walk_body(&if_stmt.body) { @@ -514,7 +592,17 @@ impl WacWalker { if last_ids.len() == 1 { Some((branch_node_id, last_ids.into_iter().next().unwrap())) } else { - Some((branch_node_id, merge_id)) + let merge_id = format!("{branch_id}_merge"); + let merge_node_id = self.add_node(DagNode { + id: merge_id, + node_type: DagNodeType::Merge, + label: "merge".to_string(), + line, + }); + for last in last_ids { + self.add_edge(&last, &merge_node_id, None); + } + Some((branch_node_id, merge_node_id)) } } @@ -552,11 +640,36 @@ impl WacWalker { } fn walk_while(&mut self, while_stmt: &StmtWhile) -> Option<(String, String)> { - if self.body_contains_step(&while_stmt.body) { - let line = self.line_index.line_of(while_stmt.range.start().to_usize()); - self.errors.push(validation::error_step_in_while(line)); + if !self.body_contains_step(&while_stmt.body) { + return None; } - None + + let line = self.line_index.line_of(while_stmt.range.start().to_usize()); + let condition = Self::expr_to_source(&while_stmt.test); + + let start_id = self.next_id(); + let start_node_id = self.add_node(DagNode { + id: start_id.clone(), + node_type: DagNodeType::LoopStart { iter_source: condition }, + label: "while".to_string(), + line, + }); + + if let Some((body_first, body_last)) = self.walk_body(&while_stmt.body) { + self.add_edge(&start_node_id, &body_first, None); + self.add_edge(&body_last, &start_node_id, Some("next".to_string())); + } + + let end_id = self.next_id(); + let end_node_id = self.add_node(DagNode { + id: end_id.clone(), + node_type: DagNodeType::LoopEnd, + label: "end while".to_string(), + line, + }); + self.add_edge(&start_node_id, &end_node_id, Some("done".to_string())); + + Some((start_node_id, end_node_id)) } fn walk_try(&mut self, try_stmt: &StmtTry) -> Option<(String, String)> { @@ -569,11 +682,17 @@ impl WacWalker { } }); - if has_steps { - let line = self.line_index.line_of(try_stmt.range.start().to_usize()); - self.errors.push(validation::error_step_in_try(line)); + if !has_steps { + return None; } - None + + let line = self.line_index.line_of(try_stmt.range.start().to_usize()); + self.emit_try_catch_branch( + &try_stmt.body, + &try_stmt.handlers, + &try_stmt.finalbody, + line, + ) } fn walk_try_star(&mut self, try_stmt: &StmtTryStar) -> Option<(String, String)> { @@ -586,11 +705,81 @@ impl WacWalker { } }); - if has_steps { - let line = self.line_index.line_of(try_stmt.range.start().to_usize()); - self.errors.push(validation::error_step_in_try(line)); + if !has_steps { + return None; } - None + + let line = self.line_index.line_of(try_stmt.range.start().to_usize()); + self.emit_try_catch_branch( + &try_stmt.body, + &try_stmt.handlers, + &try_stmt.finalbody, + line, + ) + } + + fn emit_try_catch_branch( + &mut self, + try_body: &[Stmt], + handlers: &[rustpython_parser::ast::ExceptHandler], + finally_body: &[Stmt], + line: usize, + ) -> Option<(String, String)> { + let branch_id = self.next_id(); + let branch_node_id = self.add_node(DagNode { + id: branch_id.clone(), + node_type: DagNodeType::Branch { condition_source: "try/except".to_string() }, + label: "try".to_string(), + line, + }); + + let mut last_ids = Vec::new(); + + // Try body + if let Some((try_first, try_last)) = self.walk_body(try_body) { + self.add_edge(&branch_node_id, &try_first, Some("try".to_string())); + last_ids.push(try_last); + } else { + last_ids.push(branch_node_id.clone()); + } + + // Except handlers + for handler in handlers { + match handler { + rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => { + if let Some((catch_first, catch_last)) = self.walk_body(&eh.body) { + self.add_edge(&branch_node_id, &catch_first, Some("except".to_string())); + last_ids.push(catch_last); + } + } + } + } + + // Finally body — sequential after merge + let merge_last = if last_ids.len() == 1 { + last_ids.into_iter().next().unwrap() + } else { + let merge_id = format!("{branch_id}_merge"); + let merge_node_id = self.add_node(DagNode { + id: merge_id, + node_type: DagNodeType::Merge, + label: "merge".to_string(), + line, + }); + for last in last_ids { + self.add_edge(&last, &merge_node_id, None); + } + merge_node_id + }; + + if !finally_body.is_empty() { + if let Some((finally_first, finally_last)) = self.walk_body(finally_body) { + self.add_edge(&merge_last, &finally_first, None); + return Some((branch_node_id, finally_last)); + } + } + + Some((branch_node_id, merge_last)) } fn walk_return(&mut self, ret: &StmtReturn) -> Option<(String, String)> { diff --git a/backend/parsers/windmill-parser-wac/src/typescript.rs b/backend/parsers/windmill-parser-wac/src/typescript.rs index bd777749ea..87fa7cef01 100644 --- a/backend/parsers/windmill-parser-wac/src/typescript.rs +++ b/backend/parsers/windmill-parser-wac/src/typescript.rs @@ -51,22 +51,29 @@ fn extract_var_name(pat: &Pat) -> Option { } } -/// Check if expr is `task(async fn)` or `task("path", async fn)`. -/// Returns Some(optional_path) if it is a task() call. +/// Check if expr is `task(async fn)`, `task("path", async fn)`, +/// `taskScript("path")`, or `taskFlow("path")`. +/// Returns Some(optional_path) if it is a task/taskScript/taskFlow call. fn extract_task_call_info(expr: &Expr) -> Option> { if let Expr::Call(call) = expr { if let Callee::Expr(callee) = &call.callee { if let Expr::Ident(ident) = callee.as_ref() { - if ident.sym.as_ref() == "task" { + let name = ident.sym.as_ref(); + if name == "task" { // task("f/path", async fn) or task(async fn) if call.args.len() == 2 { - // task("f/path", async fn) let path = extract_string_lit(&call.args[0].expr); return Some(path); } else if call.args.len() == 1 { - // task(async fn) return Some(None); } + } else if name == "taskScript" || name == "taskFlow" { + // taskScript("./helper.ts") or taskFlow("f/my_flow") + if let Some(first_arg) = call.args.first() { + let path = extract_string_lit(&first_arg.expr); + return Some(path); + } + return Some(None); } } } @@ -81,8 +88,6 @@ struct TsWacWalker { node_counter: usize, cm: Lrc, task_functions: TaskFunctions, - in_try: bool, - in_while: bool, in_nested_func: bool, } @@ -95,8 +100,6 @@ impl TsWacWalker { node_counter: 0, cm, task_functions, - in_try: false, - in_while: false, in_nested_func: false, } } @@ -224,6 +227,9 @@ impl TsWacWalker { if self.is_task_call(expr) { return true; } + if Self::is_sdk_call(expr) { + return true; + } match expr { Expr::Await(await_expr) => self.expr_contains_step(&await_expr.arg), Expr::Call(call) => { @@ -237,6 +243,19 @@ impl TsWacWalker { } } + /// Check if expr is a call to a known SDK function (step, sleep, waitForApproval) + fn is_sdk_call(expr: &Expr) -> bool { + if let Expr::Call(call) = expr { + if let Callee::Expr(callee) = &call.callee { + if let Expr::Ident(ident) = callee.as_ref() { + let name = ident.sym.as_ref(); + return name == "step" || name == "sleep" || name == "waitForApproval"; + } + } + } + false + } + fn walk_body(&mut self, stmts: &[Stmt]) -> Option<(String, String)> { let mut first_id: Option = None; let mut prev_id: Option = None; @@ -294,12 +313,16 @@ impl TsWacWalker { } fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> { - // await task_fn(...) + // await task_fn(...) / await step(...) / await sleep(...) / await waitForApproval(...) if let Expr::Await(await_expr) = expr { if let Expr::Call(call) = await_expr.arg.as_ref() { if self.is_task_call(&Expr::Call(call.clone())) { return self.emit_step(call, expr); } + // Check for SDK-level calls: step(), sleep(), waitForApproval() + if let Some(result) = self.try_emit_sdk_call(call, expr) { + return Some(result); + } } // await Promise.all([task_fn(...), ...]) if Self::is_promise_all(&await_expr.arg) { @@ -318,17 +341,70 @@ impl TsWacWalker { None } + /// Try to emit a node for SDK-level calls: step(), sleep(), waitForApproval() + fn try_emit_sdk_call(&mut self, call: &CallExpr, expr: &Expr) -> Option<(String, String)> { + let callee_name = match &call.callee { + Callee::Expr(callee) => match callee.as_ref() { + Expr::Ident(ident) => Some(ident.sym.as_ref().to_string()), + _ => None, + }, + _ => None, + }?; + + let line = self.span_line(expr.span()); + + match callee_name.as_str() { + "step" => { + // step("name", fn) — extract the name from the first string argument + let name = call + .args + .first() + .and_then(|a| extract_string_lit(&a.expr)) + .unwrap_or_else(|| "step".to_string()); + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::InlineStep { name: name.clone() }, + label: name, + line, + }); + Some((node_id.clone(), node_id)) + } + "sleep" => { + // sleep(N) — extract the duration from the first argument + let seconds = call + .args + .first() + .map(|a| { + self.cm + .span_to_snippet(a.expr.span()) + .unwrap_or_else(|_| "?".to_string()) + }) + .unwrap_or_else(|| "?".to_string()); + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::Sleep { seconds: seconds.clone() }, + label: format!("sleep({seconds})"), + line, + }); + Some((node_id.clone(), node_id)) + } + "waitForApproval" => { + let id = self.next_id(); + let node_id = self.add_node(DagNode { + id: id.clone(), + node_type: DagNodeType::WaitForApproval, + label: "waitForApproval".to_string(), + line, + }); + Some((node_id.clone(), node_id)) + } + _ => None, + } + } + fn emit_step(&mut self, call: &CallExpr, expr: &Expr) -> Option<(String, String)> { - if self.in_try { - self.errors - .push(validation::error_step_in_catch(self.span_line(expr.span()))); - return None; - } - if self.in_while { - self.errors - .push(validation::error_step_in_while(self.span_line(expr.span()))); - return None; - } if self.in_nested_func { self.errors.push(validation::error_step_in_nested_function( self.span_line(expr.span()), @@ -350,17 +426,6 @@ impl TsWacWalker { } fn emit_parallel(&mut self, promise_call: &CallExpr, expr: &Expr) -> Option<(String, String)> { - if self.in_try { - self.errors - .push(validation::error_step_in_catch(self.span_line(expr.span()))); - return None; - } - if self.in_while { - self.errors - .push(validation::error_step_in_while(self.span_line(expr.span()))); - return None; - } - let line = self.span_line(expr.span()); let start_id = self.next_id(); let start_node_id = self.add_node(DagNode { @@ -457,7 +522,16 @@ impl TsWacWalker { Some((branch_node_id, last_ids.into_iter().next().unwrap())) } else { let merge_id = format!("{branch_id}_merge"); - Some((branch_node_id, merge_id)) + let merge_node_id = self.add_node(DagNode { + id: merge_id, + node_type: DagNodeType::Merge, + label: "merge".to_string(), + line, + }); + for last in last_ids { + self.add_edge(&last, &merge_node_id, None); + } + Some((branch_node_id, merge_node_id)) } } @@ -473,7 +547,7 @@ impl TsWacWalker { return None; } let iter_source = self.expr_to_source(&for_in.right); - self.walk_loop_body_with_iter(&for_in.body, for_in.span, &iter_source) + self.walk_loop_body_with_iter(&for_in.body, for_in.span, &iter_source, "for") } fn walk_for_of(&mut self, for_of: &ForOfStmt) -> Option<(String, String)> { @@ -481,7 +555,7 @@ impl TsWacWalker { return None; } let iter_source = self.expr_to_source(&for_of.right); - self.walk_loop_body_with_iter(&for_of.body, for_of.span, &iter_source) + self.walk_loop_body_with_iter(&for_of.body, for_of.span, &iter_source, "for") } fn walk_loop_body( @@ -490,7 +564,7 @@ impl TsWacWalker { span: swc_common::Span, _label: &str, ) -> Option<(String, String)> { - self.walk_loop_body_with_iter(body, span, "...") + self.walk_loop_body_with_iter(body, span, "...", "for") } fn walk_loop_body_with_iter( @@ -498,13 +572,14 @@ impl TsWacWalker { body: &Stmt, span: swc_common::Span, iter_source: &str, + loop_label: &str, ) -> Option<(String, String)> { let line = self.span_line(span); let start_id = self.next_id(); let start_node_id = self.add_node(DagNode { id: start_id.clone(), node_type: DagNodeType::LoopStart { iter_source: iter_source.to_string() }, - label: "for".to_string(), + label: loop_label.to_string(), line, }); @@ -526,12 +601,11 @@ impl TsWacWalker { } fn walk_while(&mut self, while_stmt: &WhileStmt) -> Option<(String, String)> { - if self.stmt_contains_step(&while_stmt.body) { - self.errors.push(validation::error_step_in_while( - self.span_line(while_stmt.span), - )); + if !self.stmt_contains_step(&while_stmt.body) { + return None; } - None + let condition = self.expr_to_source(&while_stmt.test); + self.walk_loop_body_with_iter(&while_stmt.body, while_stmt.span, &condition, "while") } fn walk_try(&mut self, try_stmt: &TryStmt) -> Option<(String, String)> { @@ -545,12 +619,62 @@ impl TsWacWalker { .as_ref() .map_or(false, |f| self.body_contains_step(&f.stmts)); - if has_steps { - self.errors.push(validation::error_step_in_catch( - self.span_line(try_stmt.span), - )); + if !has_steps { + return None; } - None + + let line = self.span_line(try_stmt.span); + let branch_id = self.next_id(); + let branch_node_id = self.add_node(DagNode { + id: branch_id.clone(), + node_type: DagNodeType::Branch { condition_source: "try/catch".to_string() }, + label: "try".to_string(), + line, + }); + + let mut last_ids = Vec::new(); + + // Try body + if let Some((try_first, try_last)) = self.walk_body(&try_stmt.block.stmts) { + self.add_edge(&branch_node_id, &try_first, Some("try".to_string())); + last_ids.push(try_last); + } else { + last_ids.push(branch_node_id.clone()); + } + + // Catch body + if let Some(handler) = &try_stmt.handler { + if let Some((catch_first, catch_last)) = self.walk_body(&handler.body.stmts) { + self.add_edge(&branch_node_id, &catch_first, Some("catch".to_string())); + last_ids.push(catch_last); + } + } + + // Finally body — sequential after merge + let merge_last = if last_ids.len() == 1 { + last_ids.into_iter().next().unwrap() + } else { + let merge_id = format!("{branch_id}_merge"); + let merge_node_id = self.add_node(DagNode { + id: merge_id, + node_type: DagNodeType::Merge, + label: "merge".to_string(), + line, + }); + for last in last_ids { + self.add_edge(&last, &merge_node_id, None); + } + merge_node_id + }; + + if let Some(finalizer) = &try_stmt.finalizer { + if let Some((finally_first, finally_last)) = self.walk_body(&finalizer.stmts) { + self.add_edge(&merge_last, &finally_first, None); + return Some((branch_node_id, finally_last)); + } + } + + Some((branch_node_id, merge_last)) } fn walk_return(&mut self, ret: &ReturnStmt) -> Option<(String, String)> { @@ -632,6 +756,19 @@ pub fn parse_ts_workflow(code: &str) -> Result> { } } } + // export const main = workflow(async (...) => { ... }) + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { + if let Decl::Var(var_decl) = &export.decl { + for decl in &var_decl.decls { + if let Some(init) = &decl.init { + if let Some(result) = find_workflow_call(init, &cm) { + workflow_body = Some(result); + break; + } + } + } + } + } } let (stmts, params) = workflow_body.ok_or_else(|| { diff --git a/backend/parsers/windmill-parser-wac/src/validation.rs b/backend/parsers/windmill-parser-wac/src/validation.rs index e3a57c6811..a76d2b1c17 100644 --- a/backend/parsers/windmill-parser-wac/src/validation.rs +++ b/backend/parsers/windmill-parser-wac/src/validation.rs @@ -12,23 +12,6 @@ impl std::fmt::Display for CompileError { } } -pub fn error_step_in_try(line: usize) -> CompileError { - CompileError { - message: - "Task calls inside try/except are not allowed. Steps have built-in error handling." - .to_string(), - line, - } -} - -pub fn error_step_in_while(line: usize) -> CompileError { - CompileError { - message: "Task calls inside while loops are not allowed. Use for loops instead." - .to_string(), - line, - } -} - pub fn error_step_in_nested_function(line: usize) -> CompileError { CompileError { message: "Task calls inside nested functions, closures, or lambdas are not allowed." @@ -53,12 +36,3 @@ pub fn error_missing_await(line: usize) -> CompileError { line, } } - -pub fn error_step_in_catch(line: usize) -> CompileError { - CompileError { - message: - "Task calls inside catch blocks are not allowed. Steps have built-in error handling." - .to_string(), - line, - } -} diff --git a/backend/parsers/windmill-parser-wac/tests/python_tests.rs b/backend/parsers/windmill-parser-wac/tests/python_tests.rs index 59f0b59f5c..cf117da9e7 100644 --- a/backend/parsers/windmill-parser-wac/tests/python_tests.rs +++ b/backend/parsers/windmill-parser-wac/tests/python_tests.rs @@ -147,7 +147,7 @@ async def my_etl(items: list): } #[test] -fn test_reject_step_in_try() { +fn test_step_in_try_except() { let code = r#" import asyncio from wmill import workflow, task @@ -155,39 +155,52 @@ from wmill import workflow, task @task async def extract_data(): ... +@task +async def handle_error(): ... + @workflow async def my_etl(): try: await extract_data() except Exception: - pass + await handle_error() "#; - let result = parse_python_workflow(code); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert!(errors[0].message.contains("try/except")); + let dag = parse_python_workflow(code).expect("should parse try/except"); + // Branch(try/except), extract_data, handle_error, merge = 4 + assert_eq!(dag.nodes.len(), 4); + assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. })); + assert_eq!(dag.nodes[0].label, "try"); + assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[3].node_type, DagNodeType::Merge)); } #[test] -fn test_reject_step_in_while() { +fn test_step_in_while() { let code = r#" import asyncio from wmill import workflow, task @task -async def extract_data(): ... +async def poll_status(): ... @workflow async def my_etl(): while True: - await extract_data() + await poll_status() "#; - let result = parse_python_workflow(code); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert!(errors[0].message.contains("while")); + let dag = parse_python_workflow(code).expect("should parse while loop"); + // LoopStart, poll_status, LoopEnd = 3 + assert_eq!(dag.nodes.len(), 3); + assert!(matches!( + dag.nodes[0].node_type, + DagNodeType::LoopStart { .. } + )); + assert_eq!(dag.nodes[0].label, "while"); + assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[2].node_type, DagNodeType::LoopEnd)); } #[test] @@ -264,3 +277,89 @@ async def my_wf(x: int): _ => panic!("expected Step node"), } } + +#[test] +fn test_task_script_and_task_flow_py() { + let code = r#" +from wmill import workflow, task, task_script, task_flow + +helper = task_script("./helper.py") +pipeline = task_flow("f/etl/pipeline") + +@task() +async def process(x: str) -> str: + return f"processed: {x}" + +@workflow +async def main(x: str): + a = await process(x=x) + b = await helper(a=a) + c = await pipeline(b=b) + return {"a": a, "b": b, "c": c} +"#; + + let dag = parse_python_workflow(code).expect("should parse"); + assert_eq!(dag.nodes.len(), 4); // 3 steps + 1 return + + match &dag.nodes[1].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "helper"); + assert_eq!(script, "./helper.py"); + } + _ => panic!("expected Step node for task_script"), + } + + match &dag.nodes[2].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "pipeline"); + assert_eq!(script, "f/etl/pipeline"); + } + _ => panic!("expected Step node for task_flow"), + } +} + +#[test] +fn test_full_template_with_sdk_calls_py() { + let code = r#" +from wmill import workflow, task, task_script, step, sleep, wait_for_approval, get_resume_urls + +helper = task_script("./helper.py") + +@task() +async def process(x: str) -> str: + return f"processed: {x}" + +@workflow +async def main(x: str): + a = await process(x=x) + b = await helper(a=a) + urls = await step("get_urls", lambda: get_resume_urls()) + await sleep(1) + approval = await wait_for_approval(timeout=3600) + return {"processed": a, "helper_result": b, "approval": approval} +"#; + + let dag = parse_python_workflow(code).expect("should parse"); + // process, helper, step("get_urls"), sleep(1), wait_for_approval, return = 6 + assert_eq!(dag.nodes.len(), 6); + + match &dag.nodes[2].node_type { + DagNodeType::InlineStep { name } => { + assert_eq!(name, "get_urls"); + } + _ => panic!("expected InlineStep node, got {:?}", dag.nodes[2].node_type), + } + + match &dag.nodes[3].node_type { + DagNodeType::Sleep { seconds } => { + assert_eq!(seconds, "1"); + } + _ => panic!("expected Sleep node, got {:?}", dag.nodes[3].node_type), + } + + assert!(matches!( + dag.nodes[4].node_type, + DagNodeType::WaitForApproval + )); + assert!(matches!(dag.nodes[5].node_type, DagNodeType::Return)); +} diff --git a/backend/parsers/windmill-parser-wac/tests/ts_tests.rs b/backend/parsers/windmill-parser-wac/tests/ts_tests.rs index 949f326b90..bb006c6744 100644 --- a/backend/parsers/windmill-parser-wac/tests/ts_tests.rs +++ b/backend/parsers/windmill-parser-wac/tests/ts_tests.rs @@ -129,45 +129,56 @@ export default workflow(async (items: string[]) => { } #[test] -fn test_reject_step_in_try_catch() { +fn test_step_in_try_catch() { let code = r#" import { workflow, task } from "windmill-client"; const extract_data = task(async () => {}); +const handle_error = task(async (e: any) => {}); export default workflow(async () => { try { await extract_data(); } catch (e) { - console.log(e); + await handle_error(e); } }); "#; - let result = parse_ts_workflow(code); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert!(errors[0].message.contains("catch")); + let dag = parse_ts_workflow(code).expect("should parse try/catch"); + // Branch(try/catch), extract_data, handle_error, merge = 4 + assert_eq!(dag.nodes.len(), 4); + assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. })); + assert_eq!(dag.nodes[0].label, "try"); + assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[3].node_type, DagNodeType::Merge)); } #[test] -fn test_reject_step_in_while_ts() { +fn test_step_in_while_ts() { let code = r#" import { workflow, task } from "windmill-client"; -const extract_data = task(async () => {}); +const poll_status = task(async () => {}); export default workflow(async () => { while (true) { - await extract_data(); + await poll_status(); } }); "#; - let result = parse_ts_workflow(code); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert!(errors[0].message.contains("while")); + let dag = parse_ts_workflow(code).expect("should parse while loop"); + // LoopStart, poll_status, LoopEnd = 3 + assert_eq!(dag.nodes.len(), 3); + assert!(matches!( + dag.nodes[0].node_type, + DagNodeType::LoopStart { .. } + )); + assert_eq!(dag.nodes[0].label, "while"); + assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[2].node_type, DagNodeType::LoopEnd)); } #[test] @@ -243,3 +254,158 @@ export default workflow(async (x: number) => { _ => panic!("expected Step node"), } } + +#[test] +fn test_task_script_and_task_flow() { + let code = r#" +import { workflow, task, taskScript, taskFlow } from "windmill-client"; + +const helper = taskScript("./helper.ts"); +const pipeline = taskFlow("f/etl/pipeline"); +const process = task(async (x: string) => {}); + +export default workflow(async (x: string) => { + const a = await process(x); + const b = await helper({ a }); + const c = await pipeline({ b }); + return { a, b, c }; +}); +"#; + + let dag = parse_ts_workflow(code).expect("should parse"); + assert_eq!(dag.nodes.len(), 4); // 3 steps + 1 return + + match &dag.nodes[0].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "process"); + assert_eq!(script, "process"); + } + _ => panic!("expected Step node"), + } + + match &dag.nodes[1].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "helper"); + assert_eq!(script, "./helper.ts"); + } + _ => panic!("expected Step node for taskScript"), + } + + match &dag.nodes[2].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "pipeline"); + assert_eq!(script, "f/etl/pipeline"); + } + _ => panic!("expected Step node for taskFlow"), + } +} + +#[test] +fn test_full_template_with_sdk_calls() { + let code = r#" +import { task, taskScript, step, sleep, waitForApproval, getResumeUrls, workflow } from "windmill-client"; + +const helper = taskScript("./helper.ts"); +const process = task(async (x: string): Promise => { + return `processed: ${x}`; +}); + +export const main = workflow(async (x: string) => { + const a = await process(x); + const b = await helper({ a }); + const urls = await step("get_urls", () => getResumeUrls()); + await sleep(1); + const approval = await waitForApproval({ timeout: 3600 }); + return { processed: a, helper_result: b, approval }; +}); +"#; + + let dag = parse_ts_workflow(code).expect("should parse"); + // process, helper, step("get_urls"), sleep(1), waitForApproval, return = 6 + assert_eq!(dag.nodes.len(), 6); + assert_eq!(dag.edges.len(), 5); + + assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. })); + + match &dag.nodes[1].node_type { + DagNodeType::Step { name, script } => { + assert_eq!(name, "helper"); + assert_eq!(script, "./helper.ts"); + } + _ => panic!("expected Step node"), + } + + match &dag.nodes[2].node_type { + DagNodeType::InlineStep { name } => { + assert_eq!(name, "get_urls"); + } + _ => panic!("expected InlineStep node, got {:?}", dag.nodes[2].node_type), + } + + match &dag.nodes[3].node_type { + DagNodeType::Sleep { seconds } => { + assert_eq!(seconds, "1"); + } + _ => panic!("expected Sleep node, got {:?}", dag.nodes[3].node_type), + } + + assert!(matches!( + dag.nodes[4].node_type, + DagNodeType::WaitForApproval + )); + assert!(matches!(dag.nodes[5].node_type, DagNodeType::Return)); +} + +#[test] +fn test_complex_mixed_workflow() { + let code = r#" +import { workflow, task, step, sleep } from "windmill-client"; + +const validate = task(async (data: any) => {}); +const process_csv = task(async (data: any) => {}); +const process_json = task(async (data: any) => {}); +const enrich = task(async (item: any) => {}); +const store = task(async (data: any) => {}); + +export default workflow(async (data: any) => { + const validated = await validate(data); + if (validated.format === "csv") { + const parsed = await process_csv(validated); + for (const row of parsed.rows) { + await enrich(row); + } + } else { + await process_json(validated); + } + await sleep(5); + const ts = await step("timestamp", () => new Date().toISOString()); + await store(validated); + return { done: true }; +}); +"#; + + let dag = parse_ts_workflow(code).expect("should parse"); + + // validate, Branch, process_csv, LoopStart, enrich, LoopEnd, process_json, + // merge, sleep(5), step("timestamp"), store, return = 12 + assert_eq!(dag.nodes.len(), 12); + + assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. })); + assert!(matches!(dag.nodes[1].node_type, DagNodeType::Branch { .. })); + assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. })); // process_csv + assert!(matches!( + dag.nodes[3].node_type, + DagNodeType::LoopStart { .. } + )); + assert!(matches!(dag.nodes[4].node_type, DagNodeType::Step { .. })); // enrich + assert!(matches!(dag.nodes[5].node_type, DagNodeType::LoopEnd)); + assert!(matches!(dag.nodes[6].node_type, DagNodeType::Step { .. })); // process_json + assert!(matches!(dag.nodes[7].node_type, DagNodeType::Merge)); + assert!(matches!(dag.nodes[8].node_type, DagNodeType::Sleep { .. })); + assert!(matches!( + dag.nodes[9].node_type, + DagNodeType::InlineStep { .. } + )); // timestamp + assert!(matches!(dag.nodes[10].node_type, DagNodeType::Step { .. })); // store + assert!(matches!(dag.nodes[11].node_type, DagNodeType::Return)); +} diff --git a/backend/parsers/windmill-parser-wasm/publish-pkgs.sh b/backend/parsers/windmill-parser-wasm/publish-pkgs.sh index 3ac0ceef18..42ae54f683 100755 --- a/backend/parsers/windmill-parser-wasm/publish-pkgs.sh +++ b/backend/parsers/windmill-parser-wasm/publish-pkgs.sh @@ -39,3 +39,6 @@ popd pushd "pkg-py-imports" && npm publish ${args} popd + +pushd "pkg-wac" && npm publish ${args} +popd diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d82474e615..c4e09651d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -87,6 +87,7 @@ "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.657.2", + "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "^1.3.4", @@ -842,7 +843,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -854,7 +854,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -865,7 +864,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1355,7 +1353,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1512,7 +1509,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1529,7 +1525,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1546,7 +1541,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1563,7 +1557,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1580,7 +1573,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1597,7 +1589,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1614,7 +1605,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1631,7 +1621,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1648,7 +1637,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1665,7 +1653,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,7 +1669,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1699,7 +1685,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1716,7 +1701,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1733,7 +1717,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1750,7 +1733,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2056,7 +2038,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6865,7 +6846,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7364,7 +7345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7385,7 +7365,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7406,7 +7385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7427,7 +7405,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7448,7 +7425,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7469,7 +7445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7490,7 +7465,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7511,7 +7485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7532,7 +7505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7553,7 +7525,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7574,7 +7545,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12150,21 +12120,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12895,7 +12850,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13704,6 +13659,11 @@ "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.657.2.tgz", "integrity": "sha512-tiOUVsMKTc85m/a2BKpgAN3xTz+OPrUhcjEBPJNTdzrQOir1G5WeNkQ403BW+d1qI0BAVWT0gZnJ+4AhavBP+w==" }, + "node_modules/windmill-parser-wasm-wac": { + "version": "1.668.6", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-wac/-/windmill-parser-wasm-wac-1.668.6.tgz", + "integrity": "sha512-/ovcLWlIO+TQMrnWwcWoXquJI6hTZ+Zpo4POhV8z6e+pb4cVModGysASI9mERn2e9uj8/xSRmYqk54TLG8oUbQ==" + }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-yaml/-/windmill-parser-wasm-yaml-1.593.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c2372a4130..5c719931a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -160,6 +160,7 @@ "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", "windmill-parser-wasm-ts": "1.657.2", + "windmill-parser-wasm-wac": "1.668.6", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "^1.3.4", diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 024fe0e907..16ebfa8851 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -178,7 +178,7 @@ let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning let open: boolean = $state(false) // Is confirmation modal open let args: Record = $state(untrack(() => initialArgs)) // Test args input - let selectedInputTab: 'main' | 'preprocessor' = $state('main') + let selectedInputTab: 'main' | 'preprocessor' | 'diagram' = $state('main') let hasPreprocessor = $state(false) let preserveOnBehalfOf = $state(false) diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 2bc5fe8575..622b057886 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -19,6 +19,8 @@ } from '$lib/utils' import Editor from './Editor.svelte' import { inferArgs, inferAssets, inferAnsibleExecutionMode } from '$lib/infer' + import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' + import WacDiagram from '$lib/components/graph/WacDiagram.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' import SchemaForm from './SchemaForm.svelte' import LogPanel from './scriptEditor/LogPanel.svelte' @@ -135,7 +137,7 @@ watchChanges?: boolean customUi?: ScriptEditorWhitelabelCustomUi | undefined args: Record - selectedTab?: 'main' | 'preprocessor' + selectedTab?: 'main' | 'preprocessor' | 'diagram' hasPreprocessor?: boolean captureTable?: CaptureTable | undefined showCaptures?: boolean @@ -1204,7 +1206,8 @@ ) } - let showTabs = $derived(hasPreprocessor) + let isWac = $derived(code && lang ? isWorkflowAsCode(code, lang) : false) + let showTabs = $derived(hasPreprocessor || isWac) $effect(() => { !hasPreprocessor && (selectedTab = 'main') }) @@ -1445,6 +1448,11 @@ {/if} + {#if isWac} +
+ +
+ {/if} {/if} @@ -1467,203 +1475,209 @@ {/if} -
-
-
diff --git a/frontend/src/lib/components/graph/WacDiagram.svelte b/frontend/src/lib/components/graph/WacDiagram.svelte new file mode 100644 index 0000000000..ed7f2d22cc --- /dev/null +++ b/frontend/src/lib/components/graph/WacDiagram.svelte @@ -0,0 +1,109 @@ + + +
+ {#if errors.length > 0} +
+ {#each errors as error (error.line)} +
+ + + {#if error.line > 0} + L{error.line}: + {/if} + {error.message} + +
+ {/each} +
+ {:else if empty} +
+ + No workflow diagram +
+ {:else} + + + + + + {/if} +
diff --git a/frontend/src/lib/components/graph/renderers/edges/WacEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/WacEdge.svelte new file mode 100644 index 0000000000..6f52a28e11 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/edges/WacEdge.svelte @@ -0,0 +1,57 @@ + + + + +{#if label} + + {label} + +{/if} + +{#if animated} + +{/if} + + diff --git a/frontend/src/lib/components/graph/renderers/nodes/WacControlNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/WacControlNode.svelte new file mode 100644 index 0000000000..0da9269d7d --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/WacControlNode.svelte @@ -0,0 +1,57 @@ + + +
+
+
+
{displayLabel}
+
+
+
+ + + diff --git a/frontend/src/lib/components/graph/renderers/nodes/WacStepNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/WacStepNode.svelte new file mode 100644 index 0000000000..18e77ac946 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/WacStepNode.svelte @@ -0,0 +1,39 @@ + + +
+
+
+
+
{label}
+
+ {#if hasExternalPath} + + {script} + + {:else if isInline} + + inline + + {/if} +
+
+
+ + + diff --git a/frontend/src/lib/components/graph/wacDagLayout.ts b/frontend/src/lib/components/graph/wacDagLayout.ts new file mode 100644 index 0000000000..9f1d02f020 --- /dev/null +++ b/frontend/src/lib/components/graph/wacDagLayout.ts @@ -0,0 +1,139 @@ +import type { Node, Edge } from '@xyflow/svelte' +import type { WacWorkflowDag, WacDagNode } from '$lib/infer' +import { NODE } from './util' + +const GAP_X = NODE.gap.horizontal +const GAP_Y = NODE.gap.vertical + +/** + * Simple top-to-bottom DAG layout for WAC workflow graphs. + * Uses the same NODE dimensions as the flow editor for visual consistency. + */ +export function dagToXyflow(dag: WacWorkflowDag): { nodes: Node[]; edges: Edge[] } { + if (dag.nodes.length === 0) { + return { nodes: [], edges: [] } + } + + // Build adjacency maps + const childrenMap = new Map() + const parentMap = new Map() + + for (const edge of dag.edges) { + if (!childrenMap.has(edge.from)) childrenMap.set(edge.from, []) + childrenMap.get(edge.from)!.push({ id: edge.to, label: edge.label }) + if (!parentMap.has(edge.to)) parentMap.set(edge.to, []) + parentMap.get(edge.to)!.push(edge.from) + } + + // Find root nodes (no parents, excluding back-edges to loop starts) + const roots = dag.nodes.filter((n) => { + const parents = parentMap.get(n.id) ?? [] + return ( + parents.length === 0 || + parents.every((p) => { + const edge = dag.edges.find((e) => e.from === p && e.to === n.id) + return edge?.label === 'next' + }) + ) + }) + + // Assign layers using BFS (ignoring back-edges) + const layers = new Map() + const queue: string[] = [] + + for (const root of roots) { + layers.set(root.id, 0) + queue.push(root.id) + } + + while (queue.length > 0) { + const nodeId = queue.shift()! + const layer = layers.get(nodeId)! + const children = childrenMap.get(nodeId) ?? [] + + for (const child of children) { + if (child.label === 'next') continue // skip back-edges + const existing = layers.get(child.id) + if (existing === undefined || existing < layer + 1) { + layers.set(child.id, layer + 1) + queue.push(child.id) + } + } + } + + // Group nodes by layer + const layerGroups = new Map() + for (const [nodeId, layer] of layers) { + if (!layerGroups.has(layer)) layerGroups.set(layer, []) + layerGroups.get(layer)!.push(nodeId) + } + + const maxLayer = Math.max(...layers.values(), 0) + + // Position nodes — centered, using flow editor dimensions + const positions = new Map() + + for (let layer = 0; layer <= maxLayer; layer++) { + const group = layerGroups.get(layer) ?? [] + const totalWidth = group.length * NODE.width + (group.length - 1) * GAP_X + const startX = -totalWidth / 2 + + for (let i = 0; i < group.length; i++) { + positions.set(group[i], { + x: startX + i * (NODE.width + GAP_X), + y: layer * (NODE.height + GAP_Y) + }) + } + } + + // Convert to xyflow nodes + const nodeMap = new Map(dag.nodes.map((n) => [n.id, n])) + const xyNodes: Node[] = [] + + for (const [id, pos] of positions) { + const dagNode = nodeMap.get(id) + if (!dagNode) continue + + xyNodes.push({ + id, + type: getXyflowNodeType(dagNode), + position: { x: pos.x, y: pos.y }, + data: { dagNode }, + width: NODE.width, + height: NODE.height + }) + } + + // Convert to xyflow edges + const xyEdges: Edge[] = dag.edges.map((e, i) => ({ + id: `e-${i}`, + source: e.from, + target: e.to, + type: 'wacEdge', + label: e.label === 'next' ? '' : (e.label ?? ''), + animated: e.label === 'next', + style: e.label === 'next' ? 'stroke-dasharray: 5 5;' : undefined + })) + + return { nodes: xyNodes, edges: xyEdges } +} + +function getXyflowNodeType(node: WacDagNode): string { + switch (node.node_type.type) { + case 'Step': + case 'InlineStep': + return 'wacStep' + case 'Sleep': + case 'WaitForApproval': + case 'Branch': + case 'ParallelStart': + case 'ParallelEnd': + case 'LoopStart': + case 'LoopEnd': + case 'Merge': + case 'Return': + return 'wacControl' + default: + return 'wacStep' + } +} diff --git a/frontend/src/lib/components/graph/wacToFlow.ts b/frontend/src/lib/components/graph/wacToFlow.ts index 91d335ff91..6f2f3720e1 100644 --- a/frontend/src/lib/components/graph/wacToFlow.ts +++ b/frontend/src/lib/components/graph/wacToFlow.ts @@ -9,7 +9,7 @@ export function isWorkflowAsCode(code: string, language: string): boolean { return ( /workflow\s*\(/.test(code) && /task\s*\(/.test(code) && - /import.*(?:workflow|task).*from\s+['"]windmill-client(?:@[^'"]*)?['"]/.test(code) + /['"]windmill-client(?:@[^'"]*)?['"]/.test(code) ) } return false diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index b1b822419b..4624b777fa 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -55,6 +55,8 @@ import wasmUrlNu from 'windmill-parser-wasm-nu/windmill_parser_wasm_bg.wasm?url' import wasmUrlJava from 'windmill-parser-wasm-java/windmill_parser_wasm_bg.wasm?url' import wasmUrlRuby from 'windmill-parser-wasm-ruby/windmill_parser_wasm_bg.wasm?url' import wasmUrlAsset from 'windmill-parser-wasm-asset/windmill_parser_wasm_bg.wasm?url' +import initWacParser, { parse_workflow_as_code } from 'windmill-parser-wasm-wac' +import wasmUrlWac from 'windmill-parser-wasm-wac/windmill_parser_wasm_bg.wasm?url' import { workspaceStore } from './stores.js' import { argSigToJsonSchemaType } from 'windmill-utils-internal' import { type AssetWithAccessType } from './components/assets/lib.js' @@ -102,20 +104,77 @@ async function initWasmRuby() { async function initWasmAsset() { await initAssetParser(wasmUrlAsset) } +let initializeWacPromise: Promise | undefined = undefined +async function initWasmWac() { + if (initializeWacPromise == undefined) { + initializeWacPromise = initWacParser(wasmUrlWac) + } + await initializeWacPromise +} + +export type WacDagNode = { + id: string + node_type: + | { type: 'Step'; name: string; script: string } + | { type: 'InlineStep'; name: string } + | { type: 'Sleep'; seconds: string } + | { type: 'WaitForApproval' } + | { type: 'Branch'; condition_source: string } + | { type: 'ParallelStart' } + | { type: 'ParallelEnd' } + | { type: 'LoopStart'; iter_source: string } + | { type: 'LoopEnd' } + | { type: 'Merge' } + | { type: 'Return' } + label: string + line: number +} + +export type WacDagEdge = { + from: string + to: string + label?: string +} + +export type WacWorkflowDag = { + nodes: WacDagNode[] + edges: WacDagEdge[] + params: { name: string; typ?: string }[] + source_hash: string +} + +export async function parseWacDag( + code: string, + language: string +): Promise { + try { + await initWasmWac() + const raw = parse_workflow_as_code(code, language) + const result = JSON.parse(raw) + if (result.type === 'success') { + return result as WacWorkflowDag + } else if (result.type === 'error') { + return { errors: result.errors } + } + return null + } catch { + return null + } +} type InferAssetsResult = | { - status: 'ok' - assets: AssetWithAccessType[] - sql_queries?: InferAssetsSqlQueryDetails[] - columns?: Record - } + status: 'ok' + assets: AssetWithAccessType[] + sql_queries?: InferAssetsSqlQueryDetails[] + columns?: Record + } | { - status: 'error' - error: string - assets?: undefined - sql_queries?: undefined - } + status: 'error' + error: string + assets?: undefined + sql_queries?: undefined + } export type InferAssetsSqlQueryDetails = { query_string: string // SQL query with $1 placeholders for interpolations diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 4ba269b74a..b939422b45 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -92,6 +92,8 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' + import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow' + import WacDiagram from '$lib/components/graph/WacDiagram.svelte' let script: Script | undefined = $state() let topHash: string | undefined = $state() @@ -113,6 +115,10 @@ let previousHash: string | undefined = $state(undefined) + let isWac = $derived( + script?.content && script?.language ? isWorkflowAsCode(script.content, script.language) : false + ) + const triggersCount = writable(undefined) // Add triggers context store @@ -733,150 +739,163 @@ (showEditButtons = v)} />
{#if script} -
-
- {#if script.lock_error_logs || topHash || script.archived || script.deleted} -
- {#if script.lock_error_logs} - -

- This script has not been deployed successfully because of the following - errors: -

- -
- {/if} - {#if topHash} -
- - This hash is not HEAD (latest non-archived version at this path) : - Go to the HEAD of this path +
+
+ {#if script.lock_error_logs || topHash || script.archived || script.deleted} +
+ {#if script.lock_error_logs} + +

+ This script has not been deployed successfully because of the following + errors: +

+ +
+ {/if} + {#if topHash} +
+ + This hash is not HEAD (latest non-archived version at this path) : + Go to the HEAD of this path + + {/if} + {#if script.archived && !topHash} + This path was archived + {/if} + {#if script.deleted} + +

The content of this script was deleted (by an admin, no less)

+
+ {/if} +
+ {/if} + + {#if !emptyString(script.description)} +
+ +
+
+ {/if} +
+ + {#if deploymentInProgress} +
+ + + Deployment in progress + {#if deploymentJobId} + view job - - {/if} - {#if script.archived && !topHash} - This path was archived - {/if} - {#if script.deleted} - -

The content of this script was deleted (by an admin, no less)

-
- {/if} + {/if} +
{/if} - {#if !emptyString(script.description)} -
- + {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} + {@const hasSchema = + script.schema && Object.keys(script.schema.properties ?? {}).length > 0} +
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + {#if hasSchema} + { + runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) + }} + /> + {/if} +
+ {/if} + + {#if script?.schema?.prompt_for_ai !== undefined} + { + goto(`/scripts/edit/${script?.path}?metadata_open=true`) + }} + runnableType="script" /> -
-
- {/if} -
+ {/if} - {#if deploymentInProgress} -
- - - Deployment in progress - {#if deploymentJobId} - view job + +
+ +
+ {#if !isHubScript} + + Edited by {script.created_by || + 'unknown'} + + {/if} +
+ {#if !isHubScript} + + {truncateHash(script?.hash ?? '')} + {/if} - + {#if script?.is_template} + Template + {/if} + {#if script && script.kind !== 'script'} + + {script?.kind} + + {/if} + + +
+
+
+ {#if isWac && script.content} +
+
{/if} - -
- {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} - {@const hasSchema = - script.schema && Object.keys(script.schema.properties ?? {}).length > 0} -
- { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> - {#if hasSchema} - { - runForm?.setCode(JSON.stringify(args ?? {}, null, '\t')) - }} - /> - {/if} -
- {/if} - - {#if script?.schema?.prompt_for_ai !== undefined} - { - goto(`/scripts/edit/${script?.path}?metadata_open=true`) - }} - runnableType="script" - /> - {/if} - - -
- -
- {#if !isHubScript} - - Edited by {script.created_by || - 'unknown'} - - {/if} -
- {#if !isHubScript} - - {truncateHash(script?.hash ?? '')} - - {/if} - {#if script?.is_template} - Template - {/if} - {#if script && script.kind !== 'script'} - - {script?.kind} - - {/if} - - -
-
{/if} {/snippet}