Skip to content

UI: /required_actions page silently filters out all mapped HITL tasks (sends map_index=-1 by default) #66428

@paultmathew

Description

@paultmathew

Apache Airflow version

3.2.0 (also reproduced on main and v3-2-stable by source inspection)

What happened?

Any HITL task instance with map_index >= 0 (i.e. produced by a mapped task or a task inside a mapped @task_group via expand_kwargs / expand) is silently invisible on every Required Actions listing view in the UI:

  • Browse → Required Actions (/required_actions)
  • Per-DAG Required Actions tab (/dags/<dag_id>/required_actions)
  • Per-Run Required Actions tab (/dags/<dag_id>/runs/<run_id>/required_actions)
  • Per-Task Required Actions tab (/dags/<dag_id>/tasks/<task_id>/required_actions)

The home-page "pending required actions" badge counts the row correctly (its SQL — responded_at IS NULL AND state = DEFERRED in airflow/api_fastapi/common/parameters.py:774-783 — does not filter on map_index), so the user sees a count of N pending actions but the listing pages are empty. The per-task-instance Required Actions tab (/dags/<dag_id>/runs/<run_id>/tasks/<task_id>/mapped/<map_index>/required_actions) works because it uses the singular /taskInstances/{task_id}/{map_index}/hitlDetails endpoint instead of the listing endpoint.

Root cause

airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx (identical in main, v3-2-stable, and v3-1-stable):

const mapIndex = searchParams.get(MAP_INDEX) ?? "-1";

const { data, error, isLoading } = useTaskInstanceServiceGetHitlDetails(
  {
    
    mapIndex: parseInt(mapIndex, 10),},
  
);

When the URL has no map_index query param (the default for every entrypoint into the page), mapIndex is the literal string "-1", then parsed and sent as a hard map_index=-1 filter on the listing API call. -1 is the sentinel for non-mapped task instances, so any mapped HITL row is silently dropped server-side.

The getQueryString helper in airflow-core/src/airflow/ui/openapi-gen/requests/core/request.ts:48 only skips undefined and null values, so parseInt("-1", 10) = -1 is always serialized into the request URL.

This regression was introduced by #55776 (feat(hitl): add map_index filter to get_hitl_details endpoint, merged 2025-09-18, shipped in 3.1.0) and is still present on main today.

What you think should happen instead?

When the URL does not contain map_index, the listing call should not send a map_index filter at all, so HITL rows for both mapped and unmapped tasks show up. The map_index query parameter should remain available as an opt-in filter for users who want to drill down to a specific map index.

Suggested patch:

-  const mapIndex = searchParams.get(MAP_INDEX) ?? "-1";
+  const mapIndexParam = searchParams.get(MAP_INDEX);

-      mapIndex: parseInt(mapIndex, 10),
+      mapIndex: mapIndexParam !== null ? parseInt(mapIndexParam, 10) : undefined,

How to reproduce

import pendulum
from airflow.providers.standard.operators.hitl import ApprovalOperator
from airflow.sdk import DAG, task_group


with DAG(
    dag_id="hitl_mapped_repro",
    schedule=None,
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
):
    @task_group(group_id="g")
    def per_item(name: str):
        ApprovalOperator(task_id="review", subject=f"review {name}", fail_on_reject=True)

    per_item.expand(name=["a", "b", "c"])

Trigger the DAG. The 3 g.review instances all defer (map_index = 0, 1, 2). The home-page "Pending required actions" badge shows 3. Browse → Required Actions shows "No Required Actions found". Direct API calls confirm the data is present and the UI default is what hides it:

$ curl "$AF/api/v2/dags/~/dagRuns/~/hitlDetails?response_received=false&state=deferred" | jq '.total_entries'
3

$ curl "$AF/api/v2/dags/~/dagRuns/~/hitlDetails?response_received=false&state=deferred&map_index=-1" | jq '.total_entries'
0  # ← what the UI sends by default

$ curl "$AF/api/v2/dags/~/dagRuns/~/hitlDetails?response_received=false&state=deferred&map_index=0" | jq '.total_entries'
1  # ← row IS in the DB and reachable, just hidden by the UI's default filter

There is no URL value the user can set that disables the filter — the API parameter is strictly typed int | None, so empty string / null / NaN / wildcard values all 422; and parseInt(string, 10) in the UI always yields a number, so the OpenAPI client never produces undefined (the only value that would skip serialization in request.ts:48).

Operating system

Reproduced on Linux container (apache/airflow:3.2.0-python3.12) running locally via Docker Compose. Source inspection confirms the bug is identical on main and v3-2-stable.

Versions of Apache Airflow Providers

Standard provider with HITLOperator / ApprovalOperator (any version that supports HITL).

Deployment

Other Docker-based deployment (Astronomer-style local setup with LocalExecutor).

Anything else?

  • The same component also has a small auto-refresh predicate bug: detail.responded_at === undefined should compare to null (the API serializes the field as JSON null, not omitted), so even when the listing query is fixed the page won't auto-refresh while waiting for new actions. Happy to roll that into the same PR if maintainers are OK with it.
  • The "pending required actions" badge subquery in airflow/api_fastapi/common/parameters.py:774-783 does not include a map_index predicate, which is why it disagrees with the listing — that asymmetry is what makes the bug user-visible (UI shows "1 pending" badge but empty listing).

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:UIRelated to UI/UX. For Frontend Developers.kind:bugThis is a clearly a bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions