windmill-labs/windmill · error · Error

taskFlow("${path}") can only be called inside a workflow()

Error message

taskFlow("${path}") can only be called inside a workflow()

What it means

taskFlow(path) mirrors taskScript but composes sub-flow steps; it likewise requires an active workflow() context to register the step via ctx._nextStep. Without that context the wrapper throws this error naming the sub-flow path (typescript-client/client.ts:2292), since a flow task outside workflow() cannot be scheduled.

Source

Thrown at typescript-client/client.ts:2292

/**
 * Create a task that dispatches to a separate Windmill flow.
 *
 * @example
 * const pipeline = taskFlow("f/etl/pipeline");
 * // inside workflow: await pipeline({ input: data })
 */
export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any> {
  const name = path.split("/").pop() || path;
  const wrapper = function (...args: any[]) {
    const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
    if (ctx) {
      const kwargs = args.length === 1 && typeof args[0] === "object" && args[0] !== null
        ? args[0]
        : args.reduce((acc, v, i) => { acc[`arg${i}`] = v; return acc; }, {} as Record<string, any>);
      return ctx._nextStep(name, path, kwargs, "flow", options);
    }
    throw new Error(`taskFlow("${path}") can only be called inside a workflow()`);
  };
  Object.defineProperty(wrapper, "name", { value: name });
  (wrapper as any)._is_task = true;
  (wrapper as any)._task_path = path;
  return wrapper;
}

/**
 * Mark an async function as a workflow-as-code entry point.
 *
 * The function must be **deterministic**: given the same inputs it must call
 * tasks in the same order on every replay. Branching on task results is fine
 * (results are replayed from checkpoint), but branching on external state
 * (current time, random values, external API calls) must use `step()` to
 * checkpoint the value so replays see the same result.
 */
export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
  (fn as any)._is_workflow = true;

View on GitHub (pinned to e474e8803c)

Solutions

  1. Invoke the taskFlow function inside the workflow() callback of the enclosing workflow.
  2. Register sub-flows as steps in the correct nesting order — do not fire-and-forget them.
  3. Use a workflow test harness that installs a stub context for unit tests.
  4. Ensure no await between workflow() entry and the task call detaches the call from the active context.

Example fix

// before
const result = await mySubFlow({ x: 1 }); // outside workflow() -> throws
// after
await workflow(async () => {
  const result = await mySubFlow({ x: 1 }); // registered as a step
});
Defensive patterns

Strategy: try-catch

Validate before calling

const inWorkflow = () =>
  Boolean(_workflowCtx ?? Reflect.get(globalThis, '__wmill_wf_ctx'));

Type guard

function requireWorkflowCtx(): WorkflowCtx {
  const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, '__wmill_wf_ctx');
  if (!ctx) throw new Error('taskFlow called outside workflow()');
  return ctx;
}

Try / catch

try {
  const r = await mySubFlow({ x: 1 });
} catch (e) {
  if (e.message.includes('can only be called inside a workflow()')) {
    throw new Error('mySubFlow must be awaited inside workflow(), not top-level');
  } else throw e;
}

Prevention

When it happens

Trigger: Calling a taskFlow-created function outside the workflow() callback — at top level, in a standalone main(), or in code that lost the context (detached promise, different module scope without the registered ctx).

Common situations: Nesting flows incorrectly (calling the sub-flow task directly instead of inside workflow()); unit-testing the task without a workflow harness; context lost after awaiting an unrelated promise that broke synchronous registration order.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/74abea90bb71e0e8. Report an issue: GitHub.