feat: WAC workflow diagram visualization via WASM (#8604)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* chore: remove dead code from WAC parser and add pkg-wac to publish script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: use published windmill-parser-wasm-wac@1.668.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle undefined language prop in WacDiagram usage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve windmill-parser-wasm-wac from npm registry in lockfile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: create actual merge nodes for branch/try-catch convergence points

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-30 15:34:07 +00:00
committed by GitHub
parent ee6231590e
commit abc6b12d68
20 changed files with 1554 additions and 528 deletions
+1 -1
View File
@@ -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"
@@ -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,
}
+231 -42
View File
@@ -37,7 +37,8 @@ impl LineIndex {
/// Maps task function name → optional external path (from `@task(path="...")`)
type TaskFunctions = HashMap<String, Option<String>>;
/// 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<String> = 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)> {
@@ -51,22 +51,29 @@ fn extract_var_name(pat: &Pat) -> Option<String> {
}
}
/// 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<Option<String>> {
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<SourceMap>,
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<String> = None;
let mut prev_id: Option<String> = 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<WorkflowDag, Vec<CompileError>> {
}
}
}
// 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(|| {
@@ -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,
}
}
@@ -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));
}
@@ -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<string> => {
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));
}
@@ -39,3 +39,6 @@ popd
pushd "pkg-py-imports" && npm publish ${args}
popd
pushd "pkg-wac" && npm publish ${args}
popd
+8 -48
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<string, any> = $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)
+206 -192
View File
@@ -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<string, any>
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 @@
<Tab value="preprocessor" label="Preprocessor" />
</div>
{/if}
{#if isWac}
<div transition:slide={{ duration: 200, axis: 'x' }}>
<Tab value="diagram" label="Diagram" />
</div>
{/if}
</Tabs>
</div>
{/if}
@@ -1467,203 +1475,209 @@
</div>
{/if}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
{#if selectedTab === 'diagram'}
<div class="flex-1 min-h-0">
<WacDiagram {code} language={lang ?? ''} />
</div>
{#if !(debugMode && isDebuggableScript)}
<div class="flex flex-row gap-2">
<div
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
>
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" unifiedSize="md">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
{@const disableTriggerButton =
customUi?.previewPanel?.disableTriggerButton === true}
<Button
on:click={() => runTest()}
unifiedSize="md"
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{:else}
<div class="flex justify-center pt-1 relative">
<div class="absolute top-2 left-2">
<HideButton
hidden={false}
direction="right"
panelName="Test"
shortcut="U"
size="md"
on:click={() => {
toggleTestPanel()
}}
/>
</div>
{#if !(debugMode && isDebuggableScript)}
<div class="flex flex-row gap-2">
<div
class="flex flex-row divide-x divide-gray-800 dark:divide-gray-300 items-stretch"
>
{#if testIsLoading}
<Button on:click={jobLoader?.cancelJob} btnClasses="w-full" unifiedSize="md">
<WindmillIcon
white={true}
class="mr-2 text-white"
height="16px"
width="20px"
spin="fast"
/>
Cancel
</Button>
{:else}
{@const disableTriggerButton =
customUi?.previewPanel?.disableTriggerButton === true}
<Button
on:click={() => runTest()}
unifiedSize="md"
btnClasses="w-full {!disableTriggerButton ? 'rounded-r-none' : ''}"
variant="accent-secondary"
startIcon={{ icon: Play, classes: 'animate-none' }}
shortCut={{ Icon: CornerDownLeft }}
>
Test
</Button>
{#if !disableTriggerButton}
<CaptureButton on:openTriggers />
{/if}
{/if}
</div>
{#if lastRecording}
<Button
on:click={downloadRecording}
unifiedSize="md"
startIcon={{ icon: Download }}
iconOnly
title="Download recording"
/>
{/if}
</div>
{#if lastRecording}
<Button
on:click={downloadRecording}
unifiedSize="md"
startIcon={{ icon: Download }}
iconOnly
title="Download recording"
/>
{/if}
</div>
{/if}
<div class="absolute top-2 right-2 flex items-center gap-2">
<Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} />
<DropdownV2
size="xs"
items={[
{
displayName: 'Test & record',
icon: Disc,
action: () => recordAndTest()
}
]}
/>
</div>
</div>
<Splitpanes
horizontal
class="!max-h-[calc(100%-{debugMode && isDebuggableScript ? '83' : '43'}px)]"
>
<Pane size={33}>
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
helperScript={{
source: 'inline',
code: editorCode,
//@ts-ignore
lang: effectiveLang
}}
compact
schema={testPanelSchema}
bind:args={testPanelArgs}
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{:else}
<SchemaForm
helperScript={{
source: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{/if}
{/key}
</div>
</div>
{/if}
</Pane>
<Pane size={67} class="relative">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? testPanelArgs : args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress && !debugMode}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
{scriptProgress}
bind:this={jobProgressBar}
compact={true}
/>
{/if}
{#snippet capturesTab()}
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(lang)}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
<div class="absolute top-2 right-2 flex items-center gap-2">
<Toggle size="2xs" bind:checked={jsonView} options={{ right: 'JSON' }} />
<DropdownV2
size="xs"
items={[
{
displayName: 'Test & record',
icon: Disc,
action: () => recordAndTest()
}
]}
/>
</div>
</div>
<Splitpanes
horizontal
class="!max-h-[calc(100%-{debugMode && isDebuggableScript ? '83' : '43'}px)]"
>
<Pane size={33}>
{#if jsonView}
<div
class="py-2"
style="height: {!schemaHeight || schemaHeight < 600 ? 600 : schemaHeight}px"
data-schema-picker
>
<JsonInputs
on:select={(e) => {
if (e.detail) {
if (activeModuleTab !== null) {
testPanelArgs = e.detail
} else {
args = e.detail
}
}
}}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
{:else}
<div class="px-4">
<div class="break-words relative font-sans" bind:clientHeight={schemaHeight}>
{#key argsRender}
{#if activeModuleTab !== null}
<SchemaForm
helperScript={{
source: 'inline',
code: editorCode,
//@ts-ignore
lang: effectiveLang
}}
compact
schema={testPanelSchema}
bind:args={testPanelArgs}
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{:else}
<SchemaForm
helperScript={{
source: 'inline',
code,
//@ts-ignore
lang
}}
compact
{schema}
bind:args
bind:isValid
noVariablePicker={customUi?.previewPanel?.disableVariablePicker === true}
showSchemaExplorer
/>
{/if}
{/key}
</div>
</div>
{/if}
</Pane>
<Pane size={67} class="relative">
<LogPanel
bind:this={logPanel}
{lang}
previewJob={debugMode
? ({
id: 'debug',
logs: $debugState.logs,
result: $debugState.result,
success: !$debugState.error,
type: hasDebugResult ? 'CompletedJob' : 'QueuedJob'
} as any)
: testJob}
{pastPreviews}
previewIsLoading={debugMode
? $debugState.running && !$debugState.stopped
: testIsLoading}
{editor}
{diffEditor}
args={activeModuleTab !== null ? testPanelArgs : args}
{showCaptures}
customUi={customUi?.previewPanel}
showCustomResultPanel={showDebugPanel}
>
{#if scriptProgress && !debugMode}
<!-- Put to the slot in logpanel -->
<JobProgressBar
job={testJob}
{scriptProgress}
bind:this={jobProgressBar}
compact={true}
/>
{/if}
{#snippet capturesTab()}
<div class="h-full p-2">
<CaptureTable
bind:this={captureTable}
{hasPreprocessor}
canHavePreprocessor={canHavePreprocessor(lang)}
isFlow={false}
path={stablePathForCaptures}
canEdit={true}
on:applyArgs
on:updateSchema
on:addPreprocessor
/>
</div>
{/snippet}
{#snippet customResultPanel()}
<DebugPanel
stackFrames={$debugState.stackFrames}
scopes={$debugState.scopes}
variables={$debugState.variables}
client={dapClient}
bind:selectedFrameId={selectedDebugFrameId}
/>
{/snippet}
</LogPanel>
</Pane>
</Splitpanes>
{/if}
</div>
</Pane>
</Splitpanes>
@@ -0,0 +1,109 @@
<script lang="ts">
import { SvelteFlow, SvelteFlowProvider, Controls, type Node, type Edge } from '@xyflow/svelte'
import { parseWacDag, type WacWorkflowDag } from '$lib/infer'
import { dagToXyflow } from './wacDagLayout'
import WacStepNode from './renderers/nodes/WacStepNode.svelte'
import WacControlNode from './renderers/nodes/WacControlNode.svelte'
import WacEdge from './renderers/edges/WacEdge.svelte'
import { AlertTriangle, Workflow } from 'lucide-svelte'
interface Props {
code: string
language: string
}
let { code, language }: Props = $props()
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
let errors = $state<{ message: string; line: number }[]>([])
let empty = $state(true)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
const _code = code
const _lang = language
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
const result = await parseWacDag(_code, _lang)
if (result === null) {
nodes = []
edges = []
errors = []
empty = true
} else if ('errors' in result) {
nodes = []
edges = []
errors = result.errors
empty = false
} else {
const dag = result as WacWorkflowDag
const layout = dagToXyflow(dag)
nodes = layout.nodes
edges = layout.edges
errors = []
empty = dag.nodes.length === 0
}
}, 300)
return () => clearTimeout(debounceTimer)
})
const nodeTypes = {
wacStep: WacStepNode,
wacControl: WacControlNode
} as any
const edgeTypes = {
wacEdge: WacEdge
} as any
const proOptions = { hideAttribution: true }
</script>
<div class="w-full h-full">
{#if errors.length > 0}
<div class="p-4 flex flex-col gap-2">
{#each errors as error (error.line)}
<div class="flex items-start gap-2 text-xs text-red-500">
<AlertTriangle size={14} class="shrink-0 mt-0.5" />
<span>
{#if error.line > 0}
<span class="font-mono">L{error.line}:</span>
{/if}
{error.message}
</span>
</div>
{/each}
</div>
{:else if empty}
<div class="p-4 flex flex-col items-center justify-center h-full text-tertiary text-xs gap-2">
<Workflow size={24} />
<span>No workflow diagram</span>
</div>
{:else}
<SvelteFlowProvider>
<SvelteFlow
{nodes}
{edges}
{nodeTypes}
{edgeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
nodesDraggable={false}
elementsSelectable={false}
panOnDrag={true}
zoomOnScroll={true}
zoomOnPinch={true}
zoomOnDoubleClick={false}
preventScrolling={true}
{proOptions}
style="background: transparent;"
>
<Controls position="top-right" orientation="horizontal" showLock={false} />
</SvelteFlow>
</SvelteFlowProvider>
{/if}
</div>
@@ -0,0 +1,57 @@
<script lang="ts">
import { BaseEdge as XyBaseEdge, EdgeLabel, getBezierPath, type EdgeProps } from '@xyflow/svelte'
let {
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
label,
animated
}: EdgeProps = $props()
let [path, labelX, labelY] = $derived(
getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
curvature: 0.25
})
)
</script>
<XyBaseEdge {id} {path} interactionWidth={0} />
{#if label}
<EdgeLabel x={labelX} y={labelY}>
<span class="text-2xs text-tertiary bg-surface px-1 rounded">{label}</span>
</EdgeLabel>
{/if}
{#if animated}
<path
d={path}
fill="none"
stroke-dasharray="5 5"
stroke="var(--color-border)"
stroke-width="1"
class="animated-dash"
/>
{/if}
<style>
.animated-dash {
animation: dash 1s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { NODE } from '../../util'
import type { WacDagNode } from '$lib/infer'
let { data }: { data: { dagNode: WacDagNode } } = $props()
let dagNode = $derived(data.dagNode)
let nodeType = $derived(dagNode.node_type.type)
let displayLabel = $derived.by(() => {
const nt = dagNode.node_type
switch (nt.type) {
case 'Branch':
return nt.condition_source
case 'ParallelStart':
return 'parallel'
case 'ParallelEnd':
return 'join'
case 'LoopStart':
return `${dagNode.label} (${nt.iter_source})`
case 'LoopEnd':
return dagNode.label
case 'Sleep':
return `sleep(${nt.seconds}s)`
case 'WaitForApproval':
return 'wait for approval'
case 'Return':
return 'return'
default:
return dagNode.label
}
})
let bgClass = $derived.by(() => {
switch (nodeType) {
case 'Return':
return 'bg-surface-tertiary'
default:
return 'bg-component-virtual-node'
}
})
</script>
<div class="relative">
<div
class="w-full flex relative rounded-md drop-shadow-sm {bgClass} overflow-hidden"
style="width: {NODE.width}px; height: {NODE.height}px;"
>
<div class="flex flex-row items-center justify-center w-full p-2 text-2xs rounded-md">
<div class="truncate text-center text-emphasis">{displayLabel}</div>
</div>
</div>
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -0,0 +1,39 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { Badge } from '$lib/components/common'
import { NODE } from '../../util'
import type { WacDagNode } from '$lib/infer'
let { data }: { data: { dagNode: WacDagNode } } = $props()
let dagNode = $derived(data.dagNode)
let isInline = $derived(dagNode.node_type.type === 'InlineStep')
let label = $derived(dagNode.label)
let script = $derived(dagNode.node_type.type === 'Step' ? dagNode.node_type.script : undefined)
let hasExternalPath = $derived(script !== undefined && script !== label)
</script>
<div class="relative">
<div
class="w-full flex relative rounded-md drop-shadow-sm bg-surface-tertiary overflow-hidden"
style="width: {NODE.width}px; height: {NODE.height}px;"
>
<div class="flex flex-row items-center w-full p-2 text-2xs text-primary rounded-md gap-2">
<div class="flex flex-col flex-grow min-w-0">
<div class="truncate text-center text-emphasis">{label}</div>
</div>
{#if hasExternalPath}
<Badge color="blue" baseClass="max-w-[100px]" title={script}>
<span class="text-2xs truncate">{script}</span>
</Badge>
{:else if isInline}
<Badge color="indigo" baseClass="max-w-[60px]">
<span class="text-2xs">inline</span>
</Badge>
{/if}
</div>
</div>
</div>
<Handle type="target" position={Position.Top} isConnectable={false} />
<Handle type="source" position={Position.Bottom} isConnectable={false} />
@@ -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<string, { id: string; label?: string }[]>()
const parentMap = new Map<string, string[]>()
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<string, number>()
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<number, string[]>()
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<string, { x: number; y: number }>()
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'
}
}
@@ -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
+69 -10
View File
@@ -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<any> | 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<WacWorkflowDag | { errors: { message: string; line: number }[] } | null> {
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<string, AssetUsageAccessType>
}
status: 'ok'
assets: AssetWithAccessType[]
sql_queries?: InferAssetsSqlQueryDetails[]
columns?: Record<string, AssetUsageAccessType>
}
| {
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
@@ -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<TriggersCount | undefined>(undefined)
// Add triggers context store
@@ -733,150 +739,163 @@
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showEditButtons = v)} />
</div>
{#if script}
<div class="p-8 w-full max-w-3xl mx-auto md:min-h-[300px] flex flex-col md:justify-center">
<div class="flex flex-col gap-0.5 mb-1">
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2 my-2">
{#if script.lock_error_logs}
<Alert type="error" title="Deployment failed">
<p>
This script has not been deployed successfully because of the following
errors:
</p>
<LogViewer content={script.lock_error_logs} isLoading={false} tag={undefined} />
</Alert>
{/if}
{#if topHash}
<div class="mt-2"></div>
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="{base}/scripts/get/{topHash}?workspace={$workspaceStore}"
>Go to the HEAD of this path</a
<div class="flex flex-col h-full" class:divide-y={isWac}>
<div
class="p-8 w-full max-w-3xl mx-auto md:min-h-[300px] flex flex-col md:justify-center"
>
<div class="flex flex-col gap-0.5 mb-1">
{#if script.lock_error_logs || topHash || script.archived || script.deleted}
<div class="flex flex-col gap-2 my-2">
{#if script.lock_error_logs}
<Alert type="error" title="Deployment failed">
<p>
This script has not been deployed successfully because of the following
errors:
</p>
<LogViewer
content={script.lock_error_logs}
isLoading={false}
tag={undefined}
/>
</Alert>
{/if}
{#if topHash}
<div class="mt-2"></div>
<Alert type="warning" title="Not HEAD">
This hash is not HEAD (latest non-archived version at this path) :
<a href="{base}/scripts/get/{topHash}?workspace={$workspaceStore}"
>Go to the HEAD of this path</a
>
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<Alert type="error" title="Deleted">
<p>The content of this script was deleted (by an admin, no less)</p>
</Alert>
{/if}
</div>
{/if}
{#if !emptyString(script.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(script?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
</div>
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<Badge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
</Alert>
{/if}
{#if script.archived && !topHash}
<Alert type="error" title="Archived">This path was archived</Alert>
{/if}
{#if script.deleted}
<Alert type="error" title="Deleted">
<p>The content of this script was deleted (by an admin, no less)</p>
</Alert>
{/if}
{/if}
</Badge>
</div>
{/if}
{#if !emptyString(script.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(script?.description, 'No description')}
noPadding
<div class="flex flex-col align-left">
{#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected}
{@const hasSchema =
script.schema && Object.keys(script.schema.properties ?? {}).length > 0}
<div
class="flex flex-row justify-between min-h-12"
transition:slide={{ duration: 150 }}
>
<InputSelectedBadge
onReject={() => {
savedInputsV2?.resetSelected()
}}
{inputSelected}
/>
{#if hasSchema}
<Toggle
bind:checked={jsonView}
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
{/if}
</div>
{/if}
{#if script?.schema?.prompt_for_ai !== undefined}
<AIFormAssistant
instructions={script.schema?.prompt_for_ai as string}
onEditInstructions={() => {
goto(`/scripts/edit/${script?.path}?metadata_open=true`)
}}
runnableType="script"
/>
</div>
<div class="h-4"></div>
{/if}
</div>
{/if}
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<Badge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
bind:overrideTag
viewKeybinding
loading={runLoading}
autofocus
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
bind:args
schedulable={true}
bind:this={runForm}
{jsonView}
/>
</div>
<div class="pt-4 flex flex-row gap-1 w-full justify-end items-center">
{#if !isHubScript}
<span class="text-2xs text-secondary">
Edited <TimeAgo date={script.created_at || ''} /> by {script.created_by ||
'unknown'}
</span>
{/if}
<div class="flex flex-row gap-x-2 flex-wrap items-center">
{#if !isHubScript}
<Badge small color="gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{/if}
</Badge>
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
</div>
</div>
{#if isWac && script.content}
<div class="grow min-h-0" style="min-height: 400px;">
<WacDiagram code={script.content} language={script.language ?? ''} />
</div>
{/if}
<div class="flex flex-col align-left">
{#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected}
{@const hasSchema =
script.schema && Object.keys(script.schema.properties ?? {}).length > 0}
<div
class="flex flex-row justify-between min-h-12"
transition:slide={{ duration: 150 }}
>
<InputSelectedBadge
onReject={() => {
savedInputsV2?.resetSelected()
}}
{inputSelected}
/>
{#if hasSchema}
<Toggle
bind:checked={jsonView}
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={(e) => {
runForm?.setCode(JSON.stringify(args ?? {}, null, '\t'))
}}
/>
{/if}
</div>
{/if}
{#if script?.schema?.prompt_for_ai !== undefined}
<AIFormAssistant
instructions={script.schema?.prompt_for_ai as string}
onEditInstructions={() => {
goto(`/scripts/edit/${script?.path}?metadata_open=true`)
}}
runnableType="script"
/>
{/if}
<RunForm
bind:scheduledForStr
bind:invisible_to_owner
bind:overrideTag
viewKeybinding
loading={runLoading}
autofocus
detailed={false}
bind:isValid
runnable={script}
runAction={runScript}
bind:args
schedulable={true}
bind:this={runForm}
{jsonView}
/>
</div>
<div class="pt-4 flex flex-row gap-1 w-full justify-end items-center">
{#if !isHubScript}
<span class="text-2xs text-secondary">
Edited <TimeAgo date={script.created_at || ''} /> by {script.created_by ||
'unknown'}
</span>
{/if}
<div class="flex flex-row gap-x-2 flex-wrap items-center">
{#if !isHubScript}
<Badge small color="gray">
{truncateHash(script?.hash ?? '')}
</Badge>
{/if}
{#if script?.is_template}
<Badge color="blue">Template</Badge>
{/if}
{#if script && script.kind !== 'script'}
<Badge color="blue">
{script?.kind}
</Badge>
{/if}
<SharedBadge canWrite={can_write} extraPerms={script?.extra_perms ?? {}} />
</div>
</div>
</div>
{/if}
{/snippet}