* feat: workflow-as-code v2 with @task decorator API
Replace ctx.step("name", "script") API with @task decorators where
functions are called directly. Users no longer need to pass WorkflowCtx
or use string-based step names/script paths.
Python: @task decorator with contextvars-based implicit context
TypeScript: task() wrapper with module-level context variable
Parsers: detect @task function calls instead of ctx.step() calls
Worker: updated wrappers to set implicit context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: WAC v2 checkpoint/replay with _executing_key child dispatch
- Rust-side orchestration: parent dispatches child jobs, suspends, resumes on completion
- _executing_key in checkpoint tells child which step to execute directly
- task() throws StepSuspend(mode="step_complete") after executing target step
- result_processor handles child completion and updates parent checkpoint
- WacGraph.svelte for runtime execution visualization
- Sequential and parallel workflows tested end-to-end
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WAC v2 bundle cache, globalThis ctx sharing, description optional
- Disable bun bundle caching for WAC v2 scripts (wrapper needs
windmill-client from node_modules, not available in bundle mode)
- Use Reflect.set/get(globalThis, "__wmill_wf_ctx") to share workflow
context across dual module instances (wrapper vs user script)
- Never-resolving thenable for non-matching steps in child job mode
prevents Promise.all race conditions
- Make description field optional in NewScript API (defaults to "")
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add step() primitive for inline checkpointed steps
step() executes a function inline (no child job) and persists the result
to the checkpoint. On replay, the cached value is returned — ensuring
deterministic behavior for non-deterministic operations like Date.now()
or Math.random().
- TypeScript: step(name, fn) — executes inline, throws StepSuspend with
mode "inline_checkpoint" to persist before continuing
- Rust: InlineCheckpoint variant in WacOutput, saves to checkpoint and
resets running=false for immediate re-pickup (no zombie wait)
- Shared step counter between task() and step() via _allocKey()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add Python WAC v2 support with task(), step(), workflow()
- Python SDK: WorkflowCtx with _executing_key child mode, _alloc_key
shared counter, _run_inline_step for step(), _execute_directly and
_never_resolve for child mode, step() async function
- Python executor: WAC v2 detection, checkpoint.json writing, WAC
wrapper.py generation calling _run_workflow(), post-execution hook
into shared handle_wac_v2_output()
- Make handle_wac_v2_output pub so both bun and python executors share
the same dispatch/suspend/inline-checkpoint logic
- 17 Python tests covering dispatch, replay, parallel, conditional,
inline checkpoint, and child mode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: update sqlx prepared queries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WacGraph Tooltip→Popover, simplify wacToFlow parsers
- Fix type error: Tooltip doesn't accept text snippet, use Popover
- Extract shared helpers for task matching and block collection
- Replace linear tasks.find() with Map lookups
- Remove mutable module-level counter
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: Box::pin WAC v2 output handler to prevent stack overflow
handle_python_job's async state machine was too large when combined
with handle_wac_v2_output. Box::pin heap-allocates the future.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: merge WAC v1 and v2 task decorators to preserve backward compat
The v2 @task decorator was shadowing the v1 one, breaking WAC v1
scripts that rely on HTTP-based dispatch via /workflow_as_code/ API.
The merged decorator handles three modes:
- v2: inside @workflow context → checkpoint/replay dispatch
- v1: WM_JOB_ID set, no @workflow → HTTP API dispatch + wait_job
- standalone: no Windmill env → execute function body directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip no_main_func detection for WAC v2 scripts in TS and Python parsers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent empty/noop dispatch causing infinite requeue loop
- Validate steps.len() > 0 in WAC dispatch handler (issue 3)
- Replace noop StepSuspend throw with never-resolving promise so it
can't reach the backend as an empty dispatch (issue 4)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: Python task wrapper now converts positional args to kwargs in v2 mode
Previously only **kwargs were passed to _next_step(), silently dropping
positional arguments. Extract shared _merge_args() helper used by both
v1 and v2 paths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace unwrap() with proper error propagation in WAC arg serialization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add workspace_id filter to v2_job queries in WAC dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: prevent race condition in WAC child dispatch
Restructure dispatch to save checkpoint + suspend parent + seed child
checkpoints in a single transaction BEFORE pushing child jobs. This
ensures a fast child can't complete before the parent is suspended.
Also wrap InlineCheckpoint save + running reset in a transaction to
prevent corrupted state on crash.
Use ULID for pre-generated child job IDs (consistent with rest of API).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: include step key and child job ID in WAC error propagation
Move step_key lookup before the success check so failed child errors
include which task failed, the child job ID, and the original error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: document WAC determinism contract and step dispatch semantics
- Document that workflow functions must be deterministic across replays
- Document that WacStepDispatch.script/args are metadata, not dispatch targets
- Add comments on counter-based key allocation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: tighten WAC v2 detection to reduce false positives
Replace naive substring matching with line-aware checks that skip
comments and look for specific patterns:
- TS: import from "windmill-client" containing workflow/task
- Python: @workflow and @task decorators with wmill import
Extracted shared helpers in wac_executor.rs used by both executors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: show failed steps in WacGraph when workflow completes with errors
When flowDone is true and a pending step isn't in completedSteps,
mark it as 'failed' instead of 'running'. The failed state CSS and
XCircle icon were already defined but never triggered.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: unsuspend and fail parent when WAC child push fails
Previously if a child push failed mid-batch, the parent remained
suspended with suspend = num_steps but fewer children, hanging until
the 14-day timeout. Now the push loop catches errors and unsuspends
the parent before returning the error.
Also adds source hash validation: if the script content changes between
replays, the job fails with a clear error instead of silently feeding
stale checkpoint data into wrong steps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: clear suspend_until when unsuspending WAC parent
Set suspend_until = NULL alongside suspend = 0 in both the child
failure and all-children-complete paths, so the parent doesn't rely
on subtle pull query invariants to be re-picked-up.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add exhaustive edge case tests for WAC v2 SDK
fix: make TS task wrapper non-async to fix unawaited task flush
The async wrapper caused microtask-based thenable auto-resolution that
fired .then() and threw StepSuspend before _flushPending() could capture
unawaited steps — making the flush mechanism completely broken. Now the
thenable is returned directly without async wrapping. Backward compatible
with v1 (all code paths still return awaitables).
Tests added (59 TS + 66 Python) covering: full sequential lifecycle,
step after parallel, parallel after parallel, conditional on step result,
empty/single-task workflows, 10+ steps, falsy value preservation, inline
steps, mixed step/task, unawaited flush, child mode with parallel,
key determinism, large parallel groups, and complex mixed patterns.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: atomic checkpoint updates to prevent parallel child race condition
Replace read-modify-write pattern in handle_wac_child_completion with
atomic SQL operations:
- completed_steps merged via jsonb_set(... || jsonb_build_object(...))
so concurrent children on different workers don't overwrite each other
- suspend counter decremented atomically with RETURNING to determine
"all done" condition (instead of checking completed_steps in memory)
- suspend_until cleared in the same atomic decrement statement
Before this fix, two parallel children completing simultaneously could
both load the same checkpoint, each add their step, and save — the
second write would overwrite the first, silently losing a child result
and leaving the parent suspended forever.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cancel already-pushed children on partial WAC dispatch failure
When pushing child jobs sequentially, if pushing child N fails, children
1..N-1 are already running. Previously the error handler only unsuspended
the parent, leaving orphaned children that would complete and corrupt the
checkpoint state (decrementing suspend on an already-unsuspended parent,
potentially causing duplicate step execution on re-run).
Now on partial failure:
1. Cancel all already-pushed children (prevents them from completing
and corrupting checkpoint state)
2. Clear pending_steps from checkpoint (so parent doesn't think
children are outstanding on re-run)
3. Then unsuspend parent (so the error propagates)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip WAC duration write and child check for non-WAC parents
The duration write to workflow_as_code_status was running for every
non-flow child with a parent (error handlers, success handlers,
run_script children), even though it was only intended for WAC jobs.
Add WHERE workflow_as_code_status IS NOT NULL to skip non-WAC parents
entirely. Piggyback RETURNING pending_steps.job_ids on the same query
so WAC v2 child completion needs zero extra DB round-trips on the
success path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: seed child checkpoint in same transaction as push
The child checkpoint insert was happening before the child job was
pushed, violating the FK constraint on v2_job_status. Move it into
the push transaction so the job row exists and the child can't be
picked up before its checkpoint is ready.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: set running=false when WAC parent suspends for child dispatch
The parent job kept running=true after suspending, so workers wouldn't
pick it up when children completed and suspend reached 0. The parent
only advanced when the zombie job detector reset it (~90s). Now the
dispatch suspend sets running=false so the parent is immediately
eligible for pickup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: WAC parent suspend/unsuspend lifecycle
Keep running=true when suspending the parent so the normal pull query
(WHERE running=false) never picks it up. Keep suspend_until non-null
when decrementing suspend to 0 so the suspended pull query
(WHERE suspend_until IS NOT NULL AND suspend<=0) picks it up.
Previously: setting running=false caused infinite restart loops because
the normal pull query has no suspend check and would immediately re-pick
the parent. Clearing suspend_until on the last child prevented the
suspended pull from ever seeing it, requiring the 90s zombie detector.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add approval primitive, flow child completion, timeline fixes for WAC v2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add error propagation, task options, sleep, and parallel for WAC v2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: fix python SDK tests to use name-based keys and add new test coverage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address WAC v2 review findings (sleep timing, error marker, atomicity)
- Fix sleep using suspend=1 instead of 0 to enforce actual delay
- Add approval/sleep resume injection to Python executor
- Fix TS SDK concurrency_limit mapping (was reading wrong property)
- Namespace error marker as __wmill_error to avoid user data collision
- Wrap child completion SQL in transaction for atomicity
- Decrement suspend even when step key is missing (prevents hang)
- Expand TASK_RE to handle export const, let, var, generics
- Validate step key uniqueness before dispatch
- Log warning on checkpoint deserialization failure
- Remove unimplemented delete_after_use from SDKs
- Add TaskError exception class to Python SDK with diagnostic context
- Fix extra positional args handling and add functools.wraps
- Improve getParamNames to handle typed/destructured params
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* sqlx
* sqlx
* test: add WAC v1 e2e integration tests for TS and Python
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: revert fake test versions in typescript-client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove unused WacGraph component and strip wacToFlow to isWorkflowAsCode
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: extract shared approval/sleep resume logic into wac_executor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
718 lines
25 KiB
Rust
718 lines
25 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use rustpython_parser::{
|
|
ast::{
|
|
Expr, ExprAwait, ExprCall, ExprName, Stmt, StmtExpr, StmtFor, StmtIf, StmtReturn, StmtTry,
|
|
StmtTryStar, StmtWhile,
|
|
},
|
|
Parse,
|
|
};
|
|
|
|
use crate::dag::{DagEdge, DagNode, DagNodeType, Param, WorkflowDag};
|
|
use crate::validation::{self, CompileError};
|
|
|
|
struct LineIndex {
|
|
newline_offsets: Vec<usize>,
|
|
}
|
|
|
|
impl LineIndex {
|
|
fn new(source: &str) -> Self {
|
|
let mut offsets = vec![0];
|
|
for (i, c) in source.char_indices() {
|
|
if c == '\n' {
|
|
offsets.push(i + 1);
|
|
}
|
|
}
|
|
Self { newline_offsets: offsets }
|
|
}
|
|
|
|
fn line_of(&self, byte_offset: usize) -> usize {
|
|
match self.newline_offsets.binary_search(&byte_offset) {
|
|
Ok(line) => line + 1,
|
|
Err(line) => line,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions {
|
|
let mut tasks = HashMap::new();
|
|
for stmt in stmts {
|
|
if let Stmt::AsyncFunctionDef(func) = stmt {
|
|
for dec in &func.decorator_list {
|
|
match dec {
|
|
// @task (bare decorator)
|
|
Expr::Name(ExprName { id, .. }) if id.as_str() == "task" => {
|
|
tasks.insert(func.name.to_string(), None);
|
|
}
|
|
// @task(path="...")
|
|
Expr::Call(call) => {
|
|
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
|
|
if id.as_str() == "task" {
|
|
let path = extract_task_path_kwarg(call);
|
|
tasks.insert(func.name.to_string(), path);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
tasks
|
|
}
|
|
|
|
/// Extract the `path=` keyword argument from a `@task(path="...")` call.
|
|
fn extract_task_path_kwarg(call: &ExprCall) -> Option<String> {
|
|
for kw in &call.keywords {
|
|
if let Some(ref arg) = kw.arg {
|
|
if arg.as_str() == "path" {
|
|
if let Expr::Constant(c) = &kw.value {
|
|
if let rustpython_parser::ast::Constant::Str(s) = &c.value {
|
|
return Some(s.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
struct WacWalker {
|
|
nodes: Vec<DagNode>,
|
|
edges: Vec<DagEdge>,
|
|
errors: Vec<CompileError>,
|
|
node_counter: usize,
|
|
line_index: LineIndex,
|
|
task_functions: TaskFunctions,
|
|
in_try: bool,
|
|
in_while: bool,
|
|
in_nested_func: bool,
|
|
in_comprehension: bool,
|
|
}
|
|
|
|
impl WacWalker {
|
|
fn new(source: &str, task_functions: TaskFunctions) -> Self {
|
|
Self {
|
|
nodes: Vec::new(),
|
|
edges: Vec::new(),
|
|
errors: Vec::new(),
|
|
node_counter: 0,
|
|
line_index: LineIndex::new(source),
|
|
task_functions,
|
|
in_try: false,
|
|
in_while: false,
|
|
in_nested_func: false,
|
|
in_comprehension: false,
|
|
}
|
|
}
|
|
|
|
fn next_id(&mut self) -> String {
|
|
let id = format!("step_{}", self.node_counter);
|
|
self.node_counter += 1;
|
|
id
|
|
}
|
|
|
|
fn add_node(&mut self, node: DagNode) -> String {
|
|
let id = node.id.clone();
|
|
self.nodes.push(node);
|
|
id
|
|
}
|
|
|
|
fn add_edge(&mut self, from: &str, to: &str, label: Option<String>) {
|
|
self.edges
|
|
.push(DagEdge { from: from.to_string(), to: to.to_string(), label });
|
|
}
|
|
|
|
fn line_of_expr(&self, expr: &Expr) -> usize {
|
|
let offset = match expr {
|
|
Expr::Call(c) => c.range.start().to_usize(),
|
|
Expr::Await(a) => a.range.start().to_usize(),
|
|
Expr::Attribute(a) => a.range.start().to_usize(),
|
|
Expr::Name(n) => n.range.start().to_usize(),
|
|
_ => 0,
|
|
};
|
|
self.line_index.line_of(offset)
|
|
}
|
|
|
|
fn line_of_stmt(&self, stmt: &Stmt) -> usize {
|
|
let offset = match stmt {
|
|
Stmt::If(s) => s.range.start().to_usize(),
|
|
Stmt::For(s) => s.range.start().to_usize(),
|
|
Stmt::While(s) => s.range.start().to_usize(),
|
|
Stmt::Return(s) => s.range.start().to_usize(),
|
|
Stmt::Expr(s) => s.range.start().to_usize(),
|
|
Stmt::Try(s) => s.range.start().to_usize(),
|
|
Stmt::TryStar(s) => s.range.start().to_usize(),
|
|
Stmt::Assign(s) => s.range.start().to_usize(),
|
|
Stmt::AnnAssign(s) => s.range.start().to_usize(),
|
|
Stmt::FunctionDef(s) => s.range.start().to_usize(),
|
|
Stmt::AsyncFunctionDef(s) => s.range.start().to_usize(),
|
|
_ => 0,
|
|
};
|
|
self.line_index.line_of(offset)
|
|
}
|
|
|
|
/// Check if an expression is a call to a known @task function
|
|
fn is_task_fn_call(&self, expr: &Expr) -> bool {
|
|
if let Expr::Call(call) = expr {
|
|
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
|
|
return self.task_functions.contains_key(id.as_str());
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Check if an expression is `asyncio.gather(...)` call
|
|
fn is_asyncio_gather_call(expr: &Expr) -> bool {
|
|
if let Expr::Call(call) = expr {
|
|
if let Expr::Attribute(rustpython_parser::ast::ExprAttribute { value, attr, .. }) =
|
|
call.func.as_ref()
|
|
{
|
|
if attr.as_str() == "gather" {
|
|
if let Expr::Name(ExprName { id, .. }) = value.as_ref() {
|
|
return id.as_str() == "asyncio";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Extract step name and script from a task function call.
|
|
/// Name = function name, script = task_path or function name.
|
|
fn extract_step_info_from_task_call(&self, call: &ExprCall) -> Option<(String, String)> {
|
|
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
|
|
let name = id.to_string();
|
|
let script = self
|
|
.task_functions
|
|
.get(id.as_str())
|
|
.and_then(|p| p.clone())
|
|
.unwrap_or_else(|| name.clone());
|
|
Some((name, script))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn expr_to_source(expr: &Expr) -> String {
|
|
match expr {
|
|
Expr::Compare(c) => {
|
|
let left = Self::expr_to_source(&c.left);
|
|
if let Some(comparator) = c.comparators.first() {
|
|
let right = Self::expr_to_source(comparator);
|
|
let op = match c.ops.first() {
|
|
Some(rustpython_parser::ast::CmpOp::Gt) => ">",
|
|
Some(rustpython_parser::ast::CmpOp::Lt) => "<",
|
|
Some(rustpython_parser::ast::CmpOp::GtE) => ">=",
|
|
Some(rustpython_parser::ast::CmpOp::LtE) => "<=",
|
|
Some(rustpython_parser::ast::CmpOp::Eq) => "==",
|
|
Some(rustpython_parser::ast::CmpOp::NotEq) => "!=",
|
|
Some(rustpython_parser::ast::CmpOp::In) => "in",
|
|
Some(rustpython_parser::ast::CmpOp::NotIn) => "not in",
|
|
Some(rustpython_parser::ast::CmpOp::Is) => "is",
|
|
Some(rustpython_parser::ast::CmpOp::IsNot) => "is not",
|
|
None => "?",
|
|
};
|
|
format!("{left} {op} {right}")
|
|
} else {
|
|
left
|
|
}
|
|
}
|
|
Expr::Subscript(s) => {
|
|
let value = Self::expr_to_source(&s.value);
|
|
let slice = Self::expr_to_source(&s.slice);
|
|
format!("{value}[{slice}]")
|
|
}
|
|
Expr::Attribute(a) => {
|
|
let value = Self::expr_to_source(&a.value);
|
|
format!("{value}.{}", a.attr)
|
|
}
|
|
Expr::Name(n) => n.id.to_string(),
|
|
Expr::Constant(c) => match &c.value {
|
|
rustpython_parser::ast::Constant::Str(s) => format!("\"{s}\""),
|
|
rustpython_parser::ast::Constant::Int(i) => i.to_string(),
|
|
rustpython_parser::ast::Constant::Float(f) => f.to_string(),
|
|
rustpython_parser::ast::Constant::Bool(b) => b.to_string(),
|
|
rustpython_parser::ast::Constant::None => "None".to_string(),
|
|
_ => "...".to_string(),
|
|
},
|
|
_ => "...".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Check if a statement body contains any task function calls (recursively)
|
|
fn body_contains_step(&self, body: &[Stmt]) -> bool {
|
|
for stmt in body {
|
|
if self.stmt_contains_step(stmt) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn stmt_contains_step(&self, stmt: &Stmt) -> bool {
|
|
match stmt {
|
|
Stmt::Expr(StmtExpr { value, .. }) => self.expr_contains_step(value),
|
|
Stmt::Assign(a) => self.expr_contains_step(&a.value),
|
|
Stmt::If(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse),
|
|
Stmt::For(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse),
|
|
Stmt::While(s) => {
|
|
self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse)
|
|
}
|
|
Stmt::Try(s) => {
|
|
self.body_contains_step(&s.body)
|
|
|| self.body_contains_step(&s.orelse)
|
|
|| self.body_contains_step(&s.finalbody)
|
|
|| s.handlers.iter().any(|h| match h {
|
|
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
|
|
self.body_contains_step(&eh.body)
|
|
}
|
|
})
|
|
}
|
|
Stmt::TryStar(s) => {
|
|
self.body_contains_step(&s.body)
|
|
|| self.body_contains_step(&s.orelse)
|
|
|| self.body_contains_step(&s.finalbody)
|
|
|| s.handlers.iter().any(|h| match h {
|
|
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
|
|
self.body_contains_step(&eh.body)
|
|
}
|
|
})
|
|
}
|
|
Stmt::Return(_) => false,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn expr_contains_step(&self, expr: &Expr) -> bool {
|
|
if self.is_task_fn_call(expr) {
|
|
return true;
|
|
}
|
|
match expr {
|
|
Expr::Await(ExprAwait { value, .. }) => self.expr_contains_step(value),
|
|
Expr::Call(call) => {
|
|
if self.is_task_fn_call(&Expr::Call(call.clone())) {
|
|
return true;
|
|
}
|
|
if Self::is_asyncio_gather_call(&Expr::Call(call.clone())) {
|
|
return call.args.iter().any(|a| self.expr_contains_step(a));
|
|
}
|
|
false
|
|
}
|
|
_ => 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;
|
|
let mut prev_id: Option<String> = None;
|
|
|
|
for stmt in body {
|
|
if let Some((stmt_first, stmt_last)) = self.walk_stmt(stmt) {
|
|
if let Some(ref prev) = prev_id {
|
|
self.add_edge(prev, &stmt_first, None);
|
|
}
|
|
if first_id.is_none() {
|
|
first_id = Some(stmt_first);
|
|
}
|
|
prev_id = Some(stmt_last);
|
|
}
|
|
}
|
|
|
|
match (first_id, prev_id) {
|
|
(Some(f), Some(l)) => Some((f, l)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn walk_stmt(&mut self, stmt: &Stmt) -> Option<(String, String)> {
|
|
match stmt {
|
|
Stmt::Expr(StmtExpr { value, .. }) => self.walk_expr_stmt(value),
|
|
Stmt::Assign(a) => self.walk_expr_stmt(&a.value),
|
|
Stmt::If(if_stmt) => self.walk_if(if_stmt),
|
|
Stmt::For(for_stmt) => self.walk_for(for_stmt),
|
|
Stmt::While(while_stmt) => self.walk_while(while_stmt),
|
|
Stmt::Try(try_stmt) => self.walk_try(try_stmt),
|
|
Stmt::TryStar(try_stmt) => self.walk_try_star(try_stmt),
|
|
Stmt::Return(ret) => self.walk_return(ret),
|
|
Stmt::FunctionDef(_) | Stmt::AsyncFunctionDef(_) => {
|
|
if self.stmt_contains_step(stmt) {
|
|
self.errors.push(validation::error_step_in_nested_function(
|
|
self.line_of_stmt(stmt),
|
|
));
|
|
}
|
|
None
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> {
|
|
// await task_fn(...)
|
|
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);
|
|
}
|
|
}
|
|
// await asyncio.gather(task_fn(...), task_fn(...), ...)
|
|
if Self::is_asyncio_gather_call(value) {
|
|
if let Expr::Call(gather_call) = value.as_ref() {
|
|
return self.emit_parallel(gather_call, expr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bare task_fn() without await — validation error
|
|
if self.is_task_fn_call(expr) {
|
|
self.errors
|
|
.push(validation::error_missing_await(self.line_of_expr(expr)));
|
|
}
|
|
|
|
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),
|
|
));
|
|
return None;
|
|
}
|
|
if self.in_comprehension {
|
|
self.errors.push(validation::error_step_in_comprehension(
|
|
self.line_of_expr(expr),
|
|
));
|
|
return None;
|
|
}
|
|
|
|
let (name, script) = self
|
|
.extract_step_info_from_task_call(call)
|
|
.unwrap_or(("unknown".into(), "unknown".into()));
|
|
let id = self.next_id();
|
|
let node_id = self.add_node(DagNode {
|
|
id: id.clone(),
|
|
node_type: DagNodeType::Step { name: name.clone(), script },
|
|
label: name,
|
|
line: self.line_of_expr(expr),
|
|
});
|
|
Some((node_id.clone(), node_id))
|
|
}
|
|
|
|
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 {
|
|
id: start_id.clone(),
|
|
node_type: DagNodeType::ParallelStart,
|
|
label: "parallel".to_string(),
|
|
line,
|
|
});
|
|
|
|
let mut step_ids = Vec::new();
|
|
for arg in &gather_call.args {
|
|
// Each arg should be task_fn(...)
|
|
if let Expr::Call(call) = arg {
|
|
if self.is_task_fn_call(&Expr::Call(call.clone())) {
|
|
let (name, script) = self
|
|
.extract_step_info_from_task_call(call)
|
|
.unwrap_or(("unknown".into(), "unknown".into()));
|
|
let step_id = self.next_id();
|
|
let node_id = self.add_node(DagNode {
|
|
id: step_id.clone(),
|
|
node_type: DagNodeType::Step { name: name.clone(), script },
|
|
label: name,
|
|
line: self.line_of_expr(arg),
|
|
});
|
|
self.add_edge(&start_node_id, &node_id, None);
|
|
step_ids.push(node_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
let end_id = self.next_id();
|
|
let end_node_id = self.add_node(DagNode {
|
|
id: end_id.clone(),
|
|
node_type: DagNodeType::ParallelEnd,
|
|
label: "join".to_string(),
|
|
line,
|
|
});
|
|
|
|
for step_id in &step_ids {
|
|
self.add_edge(step_id, &end_node_id, None);
|
|
}
|
|
|
|
Some((start_node_id, end_node_id))
|
|
}
|
|
|
|
fn walk_if(&mut self, if_stmt: &StmtIf) -> Option<(String, String)> {
|
|
let has_steps_in_body = self.body_contains_step(&if_stmt.body);
|
|
let has_steps_in_else = self.body_contains_step(&if_stmt.orelse);
|
|
|
|
if !has_steps_in_body && !has_steps_in_else {
|
|
return None;
|
|
}
|
|
|
|
let line = self.line_index.line_of(if_stmt.range.start().to_usize());
|
|
let condition_source = Self::expr_to_source(&if_stmt.test);
|
|
|
|
let branch_id = self.next_id();
|
|
let branch_node_id = self.add_node(DagNode {
|
|
id: branch_id.clone(),
|
|
node_type: DagNodeType::Branch { condition_source },
|
|
label: "if".to_string(),
|
|
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) {
|
|
self.add_edge(&branch_node_id, &true_first, Some("true".to_string()));
|
|
last_ids.push(true_last);
|
|
} else {
|
|
last_ids.push(branch_node_id.clone());
|
|
}
|
|
|
|
if !if_stmt.orelse.is_empty() {
|
|
if let Some((else_first, else_last)) = self.walk_body(&if_stmt.orelse) {
|
|
self.add_edge(&branch_node_id, &else_first, Some("false".to_string()));
|
|
last_ids.push(else_last);
|
|
} else {
|
|
last_ids.push(branch_node_id.clone());
|
|
}
|
|
}
|
|
|
|
if last_ids.len() == 1 {
|
|
Some((branch_node_id, last_ids.into_iter().next().unwrap()))
|
|
} else {
|
|
Some((branch_node_id, merge_id))
|
|
}
|
|
}
|
|
|
|
fn walk_for(&mut self, for_stmt: &StmtFor) -> Option<(String, String)> {
|
|
if !self.body_contains_step(&for_stmt.body) {
|
|
return None;
|
|
}
|
|
|
|
let line = self.line_index.line_of(for_stmt.range.start().to_usize());
|
|
let iter_source = Self::expr_to_source(&for_stmt.iter);
|
|
|
|
let start_id = self.next_id();
|
|
let start_node_id = self.add_node(DagNode {
|
|
id: start_id.clone(),
|
|
node_type: DagNodeType::LoopStart { iter_source },
|
|
label: "for".to_string(),
|
|
line,
|
|
});
|
|
|
|
if let Some((body_first, body_last)) = self.walk_body(&for_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 for".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_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));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn walk_try(&mut self, try_stmt: &StmtTry) -> Option<(String, String)> {
|
|
let has_steps = self.body_contains_step(&try_stmt.body)
|
|
|| self.body_contains_step(&try_stmt.orelse)
|
|
|| self.body_contains_step(&try_stmt.finalbody)
|
|
|| try_stmt.handlers.iter().any(|h| match h {
|
|
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
|
|
self.body_contains_step(&eh.body)
|
|
}
|
|
});
|
|
|
|
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));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn walk_try_star(&mut self, try_stmt: &StmtTryStar) -> Option<(String, String)> {
|
|
let has_steps = self.body_contains_step(&try_stmt.body)
|
|
|| self.body_contains_step(&try_stmt.orelse)
|
|
|| self.body_contains_step(&try_stmt.finalbody)
|
|
|| try_stmt.handlers.iter().any(|h| match h {
|
|
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
|
|
self.body_contains_step(&eh.body)
|
|
}
|
|
});
|
|
|
|
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));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn walk_return(&mut self, ret: &StmtReturn) -> Option<(String, String)> {
|
|
let line = self.line_index.line_of(ret.range.start().to_usize());
|
|
let id = self.next_id();
|
|
let node_id = self.add_node(DagNode {
|
|
id: id.clone(),
|
|
node_type: DagNodeType::Return,
|
|
label: "return".to_string(),
|
|
line,
|
|
});
|
|
Some((node_id.clone(), node_id))
|
|
}
|
|
}
|
|
|
|
/// Extract workflow function parameters (no longer skips ctx)
|
|
fn extract_params(args: &rustpython_parser::ast::Arguments) -> Vec<Param> {
|
|
let mut params = Vec::new();
|
|
for arg_with_default in args.args.iter().chain(args.posonlyargs.iter()) {
|
|
let name = arg_with_default.def.arg.to_string();
|
|
let typ = arg_with_default
|
|
.def
|
|
.annotation
|
|
.as_ref()
|
|
.map(|ann| WacWalker::expr_to_source(ann));
|
|
params.push(Param { name, typ });
|
|
}
|
|
params
|
|
}
|
|
|
|
pub fn parse_python_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
|
|
let ast = rustpython_parser::ast::Suite::parse(code, "<workflow>")
|
|
.map_err(|e| vec![CompileError { message: format!("Parse error: {e}"), line: 0 }])?;
|
|
|
|
// First pass: collect @task functions
|
|
let task_functions = collect_task_functions(&ast);
|
|
|
|
// Find the @workflow async def
|
|
let workflow_fn = ast.iter().find_map(|stmt| {
|
|
if let Stmt::AsyncFunctionDef(func) = stmt {
|
|
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
|
|
if let Expr::Name(ExprName { id, .. }) = dec {
|
|
id.as_str() == "workflow"
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
if has_workflow_decorator {
|
|
return Some(func);
|
|
}
|
|
}
|
|
// Also check non-async for error reporting
|
|
if let Stmt::FunctionDef(func) = stmt {
|
|
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
|
|
if let Expr::Name(ExprName { id, .. }) = dec {
|
|
id.as_str() == "workflow"
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
if has_workflow_decorator {
|
|
return None; // Will be reported as not-async below
|
|
}
|
|
}
|
|
None
|
|
});
|
|
|
|
// Check for non-async workflow function
|
|
let non_async_workflow = ast.iter().find_map(|stmt| {
|
|
if let Stmt::FunctionDef(func) = stmt {
|
|
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
|
|
if let Expr::Name(ExprName { id, .. }) = dec {
|
|
id.as_str() == "workflow"
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
if has_workflow_decorator {
|
|
let line_index = LineIndex::new(code);
|
|
return Some(line_index.line_of(func.range.start().to_usize()));
|
|
}
|
|
}
|
|
None
|
|
});
|
|
|
|
if let Some(line) = non_async_workflow {
|
|
if workflow_fn.is_none() {
|
|
return Err(vec![validation::error_not_async(line)]);
|
|
}
|
|
}
|
|
|
|
let workflow_fn = workflow_fn.ok_or_else(|| {
|
|
vec![CompileError { message: "No @workflow async function found.".to_string(), line: 0 }]
|
|
})?;
|
|
|
|
let params = extract_params(&workflow_fn.args);
|
|
let source_hash = compute_source_hash(code);
|
|
|
|
let mut walker = WacWalker::new(code, task_functions);
|
|
walker.walk_body(&workflow_fn.body);
|
|
|
|
if !walker.errors.is_empty() {
|
|
return Err(walker.errors);
|
|
}
|
|
|
|
Ok(WorkflowDag { nodes: walker.nodes, edges: walker.edges, params, source_hash })
|
|
}
|
|
|
|
fn compute_source_hash(code: &str) -> String {
|
|
use sha2::{Digest, Sha256};
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(code.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
trait ToUsize {
|
|
fn to_usize(self) -> usize;
|
|
}
|
|
|
|
impl ToUsize for rustpython_parser::text_size::TextSize {
|
|
fn to_usize(self) -> usize {
|
|
u32::from(self) as usize
|
|
}
|
|
}
|