This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 19d20a091dc UI: Add 'Mark as Skipped' option to Task Instance actions 
(#72425)
19d20a091dc is described below

commit 19d20a091dc43cf65ce61082b5498350824fa64b
Author: ccoliu <[email protected]>
AuthorDate: Thu Oct 8 04:34:28 2026 +0800

    UI: Add 'Mark as Skipped' option to Task Instance actions (#72425)
    
    * UI: Add 'Mark as Skipped' option to Task Instance actions
    
    * Format translate calls in MarkAs buttons to satisfy Prettier
    
    * Restore blank line before return in MarkAs buttons to satisfy ESLint
    
    The previous prettier auto-fix collapsed the translate() call but also
    dropped the blank line before return, tripping 
padding-line-between-statements.
    
    * Trigger CI rerun (Firefox e2e flake)
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
    
    * Limit Mark as Skipped to pending task instances
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 airflow-core/docs/core-concepts/tasks.rst          | 23 +++++++-
 .../airflow/ui/public/i18n/locales/en/dags.json    |  1 +
 .../TaskInstance/MarkTaskInstanceAsButton.tsx      | 17 ++++--
 .../TaskInstance/MarkTaskInstanceAsDialog.test.tsx | 69 +++++++++++++++++++---
 .../TaskInstance/MarkTaskInstanceAsDialog.tsx      | 26 +++++---
 .../components/MarkAs/{utils.ts => utils.test.ts}  | 30 +++++++++-
 .../src/airflow/ui/src/components/MarkAs/utils.ts  | 12 +++-
 7 files changed, 152 insertions(+), 26 deletions(-)

diff --git a/airflow-core/docs/core-concepts/tasks.rst 
b/airflow-core/docs/core-concepts/tasks.rst
index 0e125fda92d..e56fed21e37 100644
--- a/airflow-core/docs/core-concepts/tasks.rst
+++ b/airflow-core/docs/core-concepts/tasks.rst
@@ -78,7 +78,7 @@ The possible states for a Task Instance are:
 * ``success``: The task finished running without errors
 * ``restarting``: The task was externally requested to restart when it was 
running
 * ``failed``: The task had an error during execution and failed to run
-* ``skipped``: The task was skipped due to branching, LatestOnly, or similar.
+* ``skipped``: The task was skipped due to branching, LatestOnly, or similar, 
or was :ref:`marked as skipped manually <concepts:task-mark-as-skipped>`.
 * ``upstream_failed``: An upstream task failed and the :ref:`Trigger Rule 
<concepts:trigger-rules>` says we needed it
 * ``up_for_retry``: The task failed, but has retry attempts left and will be 
rescheduled.
 * ``up_for_reschedule``: The task is a :doc:`Sensor <sensors>` that is in 
``reschedule`` mode
@@ -90,6 +90,27 @@ The possible states for a Task Instance are:
 
 Ideally, a task should flow from ``none``, to ``scheduled``, to ``queued``, to 
``running``, and finally to ``success``.
 
+.. _concepts:task-mark-as-skipped:
+
+Marking a Task Instance as skipped
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+From the UI, an operator can mark a Task Instance as ``skipped`` to record 
that it was intentionally not
+run for a Dag Run, for example during incident recovery when an input it waits 
for is known to never
+arrive. Unlike marking it as ``success``, this does not suggest the task's 
work was done.
+
+Keep the following in mind:
+
+* Only Task Instances that have not started running can be marked as skipped: 
those with no state, or in
+  the ``scheduled``, ``up_for_retry`` or ``up_for_reschedule`` state.
+* Only the selected Task Instance is affected; upstream, downstream, past and 
future Task Instances are
+  not changed.
+* Marking a task as skipped does not bypass the Dag's dependencies. Downstream 
tasks are still evaluated
+  according to their :ref:`trigger rules <concepts:trigger-rules>`. With the 
default ``all_success`` rule,
+  the skip cascades and downstream tasks are skipped as well. For downstream 
tasks to still run, they need
+  a trigger rule that tolerates skipped upstream tasks, such as 
``none_failed`` or
+  ``none_failed_min_one_success``.
+
 When any custom Task (Operator) is running, it will get a copy of the task 
instance passed to it; as well as being able to inspect task metadata, it also 
contains methods for things like :doc:`xcoms`.
 
 
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 237dc358f86..8ab7d375ac3 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -84,6 +84,7 @@
         "failed": "Press shift+f to mark as failed",
         "success": "Press shift+s to mark as success"
       },
+      "skippedInfo": "Marking a task instance as skipped records that it was 
intentionally not run. Downstream tasks are still evaluated by their trigger 
rules: with the default \"all_success\" rule they will also be skipped. Only 
task instances that have not started running can be skipped, and only this task 
instance is affected.",
       "title": "Mark {{type}} as {{state}}"
     },
     "options": {
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
index d6708e2ae5a..e4d3e24390b 100644
--- 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
@@ -33,7 +33,7 @@ import { StateBadge } from "src/components/StateBadge";
 import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import { useShortcut } from "src/hooks/useShortcut";
 
-import { allowedStates } from "../utils";
+import { allowedStates, canMarkTaskInstanceAsSkipped } from "../utils";
 import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
 
 type Props = {
@@ -69,6 +69,10 @@ export const MarkTaskInstanceAsButton = ({ isHotkeyEnabled = 
false, taskInstance
     type: translate("taskInstance_one"),
   });
 
+  const menuStates: Array<TaskInstanceState> = 
canMarkTaskInstanceAsSkipped(taskInstance.state)
+    ? [...allowedStates, "skipped"]
+    : allowedStates;
+
   return (
     <div>
       <Menu.Root positioning={{ gutter: 0, placement: "bottom" }} 
tooltipLabel={label}>
@@ -82,16 +86,17 @@ export const MarkTaskInstanceAsButton = ({ isHotkeyEnabled 
= false, taskInstance
           </IconButton>
         </Menu.Trigger>
         <Menu.Content>
-          {allowedStates.map((menuState) => {
-            const content = translate(
-              `dags:runAndTaskActions.markAs.buttonTooltip.${menuState === 
"success" ? "success" : "failed"}`,
-            );
+          {menuStates.map((menuState) => {
+            const hasShortcut = menuState === "success" || menuState === 
"failed";
+            const content = hasShortcut
+              ? 
translate(`dags:runAndTaskActions.markAs.buttonTooltip.${menuState}`)
+              : undefined;
 
             return (
               <Tooltip
                 closeDelay={100}
                 content={content}
-                disabled={!isHotkeyEnabled}
+                disabled={!isHotkeyEnabled || !hasShortcut}
                 key={menuState}
                 openDelay={100}
               >
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
index e519ec3913c..cb5123abeaa 100644
--- 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
@@ -18,14 +18,25 @@
  */
 import "@testing-library/jest-dom/vitest";
 import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 
+import type * as UserSettings from "src/hooks/useUserSettings";
 import { Wrapper } from "src/utils/Wrapper";
 
 import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
 
+const mocks = vi.hoisted(() => ({
+  defaultOptions: [] as Array<string>,
+  dryRun: vi.fn(),
+}));
+
+vi.mock("src/hooks/useUserSettings", async (importOriginal) => ({
+  ...(await importOriginal<typeof UserSettings>()),
+  useMarkTaskInstanceDefaultOptions: () => [mocks.defaultOptions, vi.fn()],
+}));
+
 vi.mock("src/queries/usePatchTaskInstance", () => ({
   usePatchTaskInstance: () => ({
     isPending: false,
@@ -34,15 +45,24 @@ vi.mock("src/queries/usePatchTaskInstance", () => ({
 }));
 
 vi.mock("src/queries/usePatchTaskInstanceDryRun", () => ({
-  usePatchTaskInstanceDryRun: () => ({
-    data: {
-      task_instances: [],
-      total_entries: 0,
-    },
-    isPending: false,
-  }),
+  usePatchTaskInstanceDryRun: (args: unknown) => {
+    mocks.dryRun(args);
+
+    return {
+      data: {
+        task_instances: [],
+        total_entries: 0,
+      },
+      isPending: false,
+    };
+  },
 }));
 
+afterEach(() => {
+  mocks.defaultOptions = [];
+  mocks.dryRun.mockClear();
+});
+
 const taskInstance: TaskInstanceResponse = {
   dag_display_name: "Test DAG",
   dag_id: "test_dag",
@@ -88,4 +108,37 @@ describe("MarkTaskInstanceAsDialog", () => {
 
     expect(screen.getByRole("button", { name: /downstream/iu 
})).not.toHaveAttribute("data-selected");
   });
+
+  it("does not show the skipped info for other states", () => {
+    render(<MarkTaskInstanceAsDialog onClose={vi.fn()} open state="success" 
taskInstance={taskInstance} />, {
+      wrapper: Wrapper,
+    });
+
+    
expect(screen.queryByText("dags:runAndTaskActions.markAs.skippedInfo")).not.toBeInTheDocument();
+    expect(screen.getByRole("button", { name: /downstream/iu })).toBeEnabled();
+  });
+
+  it("shows the info text and limits skipping to the selected task instance", 
() => {
+    mocks.defaultOptions = ["past", "future", "upstream", "downstream"];
+
+    render(<MarkTaskInstanceAsDialog onClose={vi.fn()} open state="skipped" 
taskInstance={taskInstance} />, {
+      wrapper: Wrapper,
+    });
+
+    
expect(screen.getByText("dags:runAndTaskActions.markAs.skippedInfo")).toBeInTheDocument();
+    for (const option of [/past/iu, /future/iu, /upstream/iu, /downstream/iu]) 
{
+      expect(screen.getByRole("button", { name: option })).toBeDisabled();
+    }
+    expect(mocks.dryRun).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        requestBody: expect.objectContaining({
+          include_downstream: false,
+          include_future: false,
+          include_past: false,
+          include_upstream: false,
+          new_state: "skipped",
+        }) as unknown,
+      }),
+    );
+  });
 });
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
index 4567cdaf598..71bef2fc8f6 100644
--- 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
 
 import type { TaskInstanceResponse, TaskInstanceState } from 
"openapi/requests/types.gen";
 
-import { Modal, SegmentedControl } from "src/system-components";
+import { Alert, Modal, SegmentedControl } from "src/system-components";
 
 import { ActionAccordion } from "src/components/ActionAccordion";
 import { StateBadge } from "src/components/StateBadge";
@@ -50,10 +50,12 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state, 
taskInstance }: Props)
   const [markTaskInstanceDefaultOptions] = useMarkTaskInstanceDefaultOptions();
   const [selectedOptions, setSelectedOptions] = 
useState<Array<string>>(markTaskInstanceDefaultOptions);
 
-  const past = selectedOptions.includes("past");
-  const future = selectedOptions.includes("future");
-  const upstream = selectedOptions.includes("upstream");
-  const downstream = selectedOptions.includes("downstream");
+  // Skipping is limited to this task instance, so the options affecting other 
task instances are ignored.
+  const isSkipped = state === "skipped";
+  const past = !isSkipped && selectedOptions.includes("past");
+  const future = !isSkipped && selectedOptions.includes("future");
+  const upstream = !isSkipped && selectedOptions.includes("upstream");
+  const downstream = !isSkipped && selectedOptions.includes("downstream");
 
   const [note, setNote] = useState<string | null>(taskInstance.note);
 
@@ -144,31 +146,39 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state, 
taskInstance }: Props)
     >
       <Flex justifyContent="center">
         <SegmentedControl
-          defaultValues={markTaskInstanceDefaultOptions}
+          defaultValues={isSkipped ? [] : markTaskInstanceDefaultOptions}
+          key={isSkipped ? "skipped" : "default"}
           multiple
           onChange={setSelectedOptions}
           options={[
             {
-              disabled: taskInstance.logical_date === null,
+              disabled: isSkipped || taskInstance.logical_date === null,
               label: translate("dags:runAndTaskActions.options.past"),
               value: "past",
             },
             {
-              disabled: taskInstance.logical_date === null,
+              disabled: isSkipped || taskInstance.logical_date === null,
               label: translate("dags:runAndTaskActions.options.future"),
               value: "future",
             },
             {
+              disabled: isSkipped,
               label: translate("dags:runAndTaskActions.options.upstream"),
               value: "upstream",
             },
             {
+              disabled: isSkipped,
               label: translate("dags:runAndTaskActions.options.downstream"),
               value: "downstream",
             },
           ]}
         />
       </Flex>
+      {isSkipped ? (
+        <Alert mb={3} status="info">
+          {translate("dags:runAndTaskActions.markAs.skippedInfo")}
+        </Alert>
+      ) : undefined}
       <ActionAccordion affectedTasks={affectedTasks} note={note} 
setNote={setNote} />
     </Modal>
   );
diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts 
b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
similarity index 52%
copy from airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
copy to airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
index 5d62fd27b94..6f95cc8f14a 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
@@ -16,6 +16,32 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { DagRunMutableStates } from "openapi/requests/types.gen";
+import { describe, expect, it } from "vitest";
 
-export const allowedStates: Array<DagRunMutableStates> = ["success", "failed"];
+import type { TaskInstanceState } from "openapi/requests/types.gen";
+
+import { canMarkTaskInstanceAsSkipped } from "./utils";
+
+describe("canMarkTaskInstanceAsSkipped", () => {
+  it.each([null, undefined, "scheduled", "up_for_reschedule", "up_for_retry"] 
as const)(
+    "allows %s",
+    (state) => {
+      expect(canMarkTaskInstanceAsSkipped(state)).toBe(true);
+    },
+  );
+
+  it.each<TaskInstanceState>([
+    "queued",
+    "running",
+    "deferred",
+    "restarting",
+    "awaiting_input",
+    "success",
+    "failed",
+    "skipped",
+    "upstream_failed",
+    "removed",
+  ])("does not allow %s", (state) => {
+    expect(canMarkTaskInstanceAsSkipped(state)).toBe(false);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts 
b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
index 5d62fd27b94..2bb60e015ef 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
@@ -16,6 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { DagRunMutableStates } from "openapi/requests/types.gen";
+import type { DagRunMutableStates, TaskInstanceState } from 
"openapi/requests/types.gen";
 
 export const allowedStates: Array<DagRunMutableStates> = ["success", "failed"];
+
+// Narrower than isStatePending: excludes states where the task has started or 
may start at any moment.
+const skippableTaskInstanceStates: Array<TaskInstanceState> = [
+  "scheduled",
+  "up_for_reschedule",
+  "up_for_retry",
+];
+
+export const canMarkTaskInstanceAsSkipped = (state?: TaskInstanceState | null) 
=>
+  state === null || state === undefined || 
skippableTaskInstanceStates.includes(state);

Reply via email to