mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
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:
+1
-1
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
-48
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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/> "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/> "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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user