openapi: 3.0.3
info:
  title: Leoflow Public API
  description: |
    Airflow-3.2.x-compatible subset of the public API.
    This is the surface consumed by the Airflow UI when configured to talk to a Leoflow Control Plane.
  version: "1.0.0"
  license:
    name: Apache 2.0
    url: https://www.apache.org/licenses/LICENSE-2.0

servers:
  - url: http://localhost:8080
    description: Local development

security:
  - bearerAuth: []

paths:
  /auth/token:
    post:
      summary: Issue a JWT for username/password credentials
      operationId: issueToken
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenRequest"
      responses:
        "200":
          description: Token issued
          headers:
            Set-Cookie:
              description: >-
                The browser session cookie (`_token`), set server-side with
                HttpOnly, Secure and SameSite=Lax, path `/`, and the token's TTL
                as its Max-Age. It is the same cookie the OIDC callback sets, so
                a password login replaces an SSO session rather than layering
                under it. An API client can ignore it entirely and read
                `access_token` from the body; a browser is signed in by it from
                this response onward and never has to write it from script.
                Secure is dropped only when the operator sets
                `auth.session_cookie_insecure`. It is omitted when the browser
                reports the request as cross-origin (`Sec-Fetch-Site` other than
                `same-origin` or `none`), so a page on another origin cannot
                sign this browser in as an account it holds credentials for; the
                body is unchanged in that case. A caller that sends no
                `Sec-Fetch-Site` at all always receives it: that is every
                non-browser client, which keeps no cookie jar, and also a
                browser on a plain-http origin that is not loopback, since
                fetch metadata is only sent to potentially trustworthy URLs.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v2/auth/token/renew:
    post:
      summary: Renew a still-valid JWT into a fresh short-lived token
      description: >-
        Transparent renewal: given a still-valid user bearer, re-mints the same
        identity with a fresh short TTL, bounded by a server-side max_lifetime
        measured from first login. Lets a long CLI/dev session avoid re-logging in
        every token TTL while keeping the access token short-lived. Returns 401
        when the presented token is invalid, expired, or past max_lifetime, in
        which case the client must log in again.
      operationId: renewToken
      tags: [Auth]
      responses:
        "200":
          description: Token renewed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v2/users:
    get:
      summary: List users
      description: |
        Lists the tenant's control-plane accounts, newest first. Each entry
        carries the full set of roles the user holds; the password and its hash
        are write-only and never returned. Requires the read:user permission.
      operationId: listUsers
      tags: [Users]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of users
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UserCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create a user
      description: |
        Admin-only. Creates a control-plane account with the given email and
        password and grants the requested roles. The password is write-only and
        is never returned. Requires the write:user permission.
      operationId: createUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "400":
          description: Invalid input (missing fields or unknown role)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: A user with this email already exists in the tenant
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v2/connections:
    get:
      summary: List connections
      description: |
        Lists the tenant's Airflow-style connections. Passwords are write-only and
        never returned; secret-bearing keys inside `extra` are masked server-side.
        Requires the read:connection permission.
      operationId: listConnections
      tags: [Connections]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of connections
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectionCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create or replace a connection (upsert)
      description: |
        Upserts a connection: creates it, or replaces an existing one with the same
        connection_id. The password is write-only and never returned. Requires the
        write:connection permission and a configured encryption key.
      operationId: createConnection
      tags: [Connections]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionBody" }
      responses:
        "201":
          description: Connection created or replaced
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "400":
          description: Invalid input (missing connection_id or conn_type)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/EncryptionUnavailable"

  /api/v2/connections/{connection_id}:
    parameters:
      - $ref: "#/components/parameters/ConnectionID"
    get:
      summary: Get a connection
      operationId: getConnection
      tags: [Connections]
      responses:
        "200":
          description: Connection detail (password omitted, extra masked)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      summary: Update a connection
      operationId: updateConnection
      tags: [Connections]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionBody" }
      responses:
        "200":
          description: Updated connection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Connection" }
        "400":
          description: Invalid input (missing conn_type)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          $ref: "#/components/responses/EncryptionUnavailable"
    delete:
      summary: Delete a connection
      operationId: deleteConnection
      tags: [Connections]
      responses:
        "204":
          $ref: "#/components/responses/NoContent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/variables:
    get:
      summary: List variables
      description: |
        Lists the tenant's Airflow-style variables. Values of secret-ish keys are
        masked server-side. Requires the read:variable permission.
      operationId: listVariables
      tags: [Variables]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of variables
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VariableCollection" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      summary: Create or replace a variable (upsert)
      description: |
        Upserts a variable: creates it, or replaces an existing one with the same
        key. Requires the write:variable permission.
      operationId: createVariable
      tags: [Variables]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VariableBody" }
      responses:
        "201":
          description: Variable created or replaced
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Variable" }
        "400":
          description: Invalid input (missing key)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v2/variables/{variable_key}:
    parameters:
      - $ref: "#/components/parameters/VariableKey"
    get:
      summary: Get a variable
      operationId: getVariable
      tags: [Variables]
      responses:
        "200":
          description: Variable detail (value masked when the key looks sensitive)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Variable" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      summary: Delete a variable
      operationId: deleteVariable
      tags: [Variables]
      responses:
        "204":
          $ref: "#/components/responses/NoContent"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v2/dags:
    get:
      summary: List DAGs
      operationId: listDags
      tags: [DAGs]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: tags
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: true
        - name: only_active
          in: query
          schema: { type: boolean, default: true }
        - name: paused
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: A page of DAGs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAGCollection"

  /api/v2/dags/{dag_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG
      operationId: getDag
      tags: [DAGs]
      responses:
        "200":
          description: DAG detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAG"
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      summary: Update DAG (typically pause/unpause)
      operationId: updateDag
      tags: [DAGs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DAGUpdate"
      responses:
        "200":
          description: Updated DAG
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAG" }

  /api/v2/dags/{dag_id}/dagVersions:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: List a DAG's registered versions
      operationId: listDagVersions
      tags: [DAGs]
      responses:
        "200":
          description: A page of DAG versions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DagVersionCollection"

  /api/v2/dags/{dag_id}/dagVersions/{version_number}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - name: version_number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      summary: Get a specific registered DAG version
      operationId: getDagVersion
      tags: [DAGs]
      responses:
        "200":
          description: DAG version detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DagVersion" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: List DAG runs
      operationId: listDagRuns
      tags: [DAG Runs]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
        - name: state
          in: query
          schema:
            type: array
            items: { type: string, enum: [queued, running, success, failed] }
          style: form
          explode: true
      responses:
        "200":
          description: A page of DAG runs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DAGRunCollection"
    post:
      summary: Trigger a DAG run
      operationId: triggerDagRun
      tags: [DAG Runs]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DAGRunCreate"
      responses:
        "200":
          description: Created DAG run
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAGRun" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
    get:
      summary: Get a DAG run
      operationId: getDagRun
      tags: [DAG Runs]
      responses:
        "200":
          description: DAG run detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DAGRun" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
    get:
      summary: List task instances of a DAG run
      operationId: listTaskInstances
      tags: [Task Instances]
      responses:
        "200":
          description: A page of task instances
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskInstanceCollection"

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
    get:
      summary: Get a task instance
      operationId: getTaskInstance
      tags: [Task Instances]
      responses:
        "200":
          description: Task instance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskInstance" }

  /api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/logs/{try_number}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
      - name: try_number
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      summary: Get task logs
      operationId: getTaskLogs
      tags: [Task Instances]
      responses:
        "200":
          description: Log content
          content:
            text/plain:
              schema: { type: string }

  /api/v2/dags/{dag_id}/clearTaskInstances:
    parameters:
      - $ref: "#/components/parameters/DagID"
    post:
      summary: Clear task instances (queue them for re-run)
      operationId: clearTaskInstances
      tags: [Task Instances]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClearTaskInstancesRequest"
      responses:
        "200":
          description: List of cleared task instances
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskInstanceCollection"

  /api/v2/xcoms/{dag_id}/{dag_run_id}/{task_id}/{key}:
    parameters:
      - $ref: "#/components/parameters/DagID"
      - $ref: "#/components/parameters/DagRunID"
      - $ref: "#/components/parameters/TaskID"
      - name: key
        in: path
        required: true
        schema: { type: string }
    get:
      summary: Read XCom value (read-only proxy for the Redis backend)
      operationId: getXcomEntry
      tags: [XCom]
      responses:
        "200":
          description: XCom value
          content:
            application/json:
              schema: { $ref: "#/components/schemas/XComEntry" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dagSources/{dag_id}:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG's source (the dag.py text)
      operationId: getDagSource
      tags: [DAGs]
      responses:
        "200":
          description: DAG source
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DagSource" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/dags/{dag_id}/spec:
    parameters:
      - $ref: "#/components/parameters/DagID"
    get:
      summary: Get a DAG's compiled spec (the dag.json artifact)
      operationId: getDagSpec
      tags: [DAGs]
      responses:
        "200":
          description: The compiled dag.json (the structured graph the scheduler runs)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: The compiled dag.json artifact (DAGSpec shape).
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v2/monitor/health:
    get:
      summary: Control-plane health (Airflow HealthInfoResponse shape)
      operationId: getMonitorHealth
      tags: [Monitor]
      responses:
        "200":
          description: Component health
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthInfo" }

  /api/v2/monitor/executor:
    get:
      summary: Executor capability and configuration
      operationId: getMonitorExecutor
      tags: [Monitor]
      responses:
        "200":
          description: Executor info
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExecutorInfo" }

  /api/v2/version:
    get:
      summary: Control-plane version (Airflow VersionInfo shape)
      operationId: getVersion
      tags: [Monitor]
      responses:
        "200":
          description: Version info
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VersionInfo" }

  /healthz:
    get:
      summary: Liveness probe
      operationId: getHealthz
      tags: [Health]
      security: []
      responses:
        "200":
          description: OK
          content: { text/plain: { schema: { type: string } } }

  /readyz:
    get:
      summary: Readiness probe
      operationId: getReadyz
      tags: [Health]
      security: []
      responses:
        "200":
          description: OK
        "503":
          description: Not ready

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    DagID:
      name: dag_id
      in: path
      required: true
      schema: { type: string }
    DagRunID:
      name: dag_run_id
      in: path
      required: true
      schema: { type: string }
    TaskID:
      name: task_id
      in: path
      required: true
      schema: { type: string }
    ConnectionID:
      name: connection_id
      in: path
      required: true
      schema: { type: string }
    VariableKey:
      name: variable_key
      in: path
      required: true
      schema: { type: string }

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NoContent:
      description: Deleted (no content)
    EncryptionUnavailable:
      description: >-
        No encryption key configured, so secrets cannot be stored. Set
        LEOFLOW_SECRET_KEY on the control plane to manage connections.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        type: { type: string }
        title: { type: string }
        detail: { type: string }
        status: { type: integer }
        instance: { type: string }

    TokenRequest:
      type: object
      required: [username, password]
      properties:
        username: { type: string }
        password: { type: string }

    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        token_type: { type: string, example: "bearer" }
        expires_in: { type: integer, example: 3600 }

    CreateUserRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          description: >-
            Login email. Normalized to lowercase, so it is unique
            case-insensitively within the tenant.
        password:
          type: string
          format: password
          writeOnly: true
          minLength: 8
          description: >-
            Plaintext password (write-only; never returned). Must be at least 8
            characters — the server rejects anything shorter with 400.
        roles:
          type: array
          items: { type: string }
          description: >-
            Names of existing roles to grant; omit or empty to grant none.

    User:
      type: object
      required: [id, email, roles, is_active, created_at]
      properties:
        id: { type: string }
        email: { type: string }
        roles:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    UserListItem:
      type: object
      required: [id, email, roles, is_active, created_at]
      description: >-
        One account in the user list. Leoflow accounts are email-keyed and carry
        a set of RBAC roles, so this diverges from the Airflow FAB users API
        (username-keyed with first_name/last_name).
      properties:
        id: { type: string }
        email: { type: string }
        roles:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    UserCollection:
      type: object
      properties:
        users:
          type: array
          items: { $ref: "#/components/schemas/UserListItem" }
        total_entries: { type: integer }

    Connection:
      type: object
      required: [connection_id, conn_type]
      description: >-
        An Airflow-style connection. The password is write-only and never
        returned; secret-bearing keys inside `extra` are masked server-side.
      properties:
        connection_id: { type: string }
        conn_type: { type: string }
        description: { type: string, nullable: true }
        host: { type: string, nullable: true }
        login: { type: string, nullable: true }
        schema: { type: string, nullable: true }
        port: { type: integer, nullable: true }
        extra: { type: string, nullable: true }

    ConnectionBody:
      type: object
      required: [conn_type]
      description: >-
        Connection create/replace payload. connection_id is required on POST
        (taken from the path on PATCH). password and extra are write-only.
        Every optional field is tri-state: omit the key to preserve the stored
        value (a partial write never wipes a field it does not mention, so the
        unreadable password survives a `--host`-only edit), send an empty string
        to clear the field, or send a value to set it. A password equal to the
        mask `***`, and any key inside `extra` whose value is exactly `***`, is
        treated as "unchanged" — so re-submitting a connection read back from GET
        (whose secrets are masked) never overwrites the real secret with the mask.
      properties:
        connection_id: { type: string }
        conn_type: { type: string }
        description: { type: string }
        host: { type: string }
        login: { type: string }
        password:
          type: string
          format: password
          writeOnly: true
        schema: { type: string }
        port: { type: integer, nullable: true }
        extra: { type: string }

    ConnectionCollection:
      type: object
      properties:
        connections:
          type: array
          items: { $ref: "#/components/schemas/Connection" }
        total_entries: { type: integer }

    Variable:
      type: object
      required: [key, value, is_encrypted]
      description: >-
        An Airflow-style variable. The value is masked server-side when the key
        looks sensitive (secret/password/token/...).
      properties:
        key: { type: string }
        value: { type: string }
        description: { type: string, nullable: true }
        is_encrypted: { type: boolean }
        team_name: { type: string, nullable: true }

    VariableBody:
      type: object
      required: [key]
      description: >-
        Variable create/replace payload. value and description are tri-state:
        omit the key to preserve the stored value, send an empty string to clear
        it, or send a value to set it. A value equal to the mask `***` for a
        sensitive-looking key (secret/password/token/...) is treated as
        "unchanged", so re-submitting a variable read back from GET (whose
        sensitive value is masked) never overwrites the real value with the mask.
      properties:
        key: { type: string }
        value: { type: string }
        description: { type: string }

    VariableCollection:
      type: object
      properties:
        variables:
          type: array
          items: { $ref: "#/components/schemas/Variable" }
        total_entries: { type: integer }

    DAG:
      type: object
      properties:
        dag_id: { type: string }
        dag_display_name: { type: string }
        description: { type: string }
        is_paused: { type: boolean }
        is_active: { type: boolean }
        owners: { type: array, items: { type: string } }
        tags:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
        schedule_interval:
          type: object
          nullable: true
        timetable_description: { type: string }
        next_dagrun: { type: string, format: date-time, nullable: true }
        last_parsed_time: { type: string, format: date-time, nullable: true }
        max_active_runs: { type: integer }
        catchup: { type: boolean }
        has_task_concurrency_limits: { type: boolean }
        fileloc: { type: string }

    DAGCollection:
      type: object
      properties:
        dags:
          type: array
          items: { $ref: "#/components/schemas/DAG" }
        total_entries: { type: integer }

    DagVersion:
      type: object
      properties:
        id: { type: string }
        version_number: { type: integer }
        dag_id: { type: string }
        dag_display_name: { type: string }
        bundle_name: { type: string }
        bundle_version: { type: string, nullable: true }
        bundle_url: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    DagVersionCollection:
      type: object
      properties:
        dag_versions:
          type: array
          items: { $ref: "#/components/schemas/DagVersion" }
        total_entries: { type: integer }

    DagSource:
      type: object
      properties:
        content: { type: string, description: "The dag.py source text (compiled-spec JSON fallback for pre-source-capture versions)." }
        dag_id: { type: string }
        version_number: { type: integer }
        dag_display_name: { type: string }

    ComponentHealth:
      type: object
      properties:
        status: { type: string }
        latest_scheduler_heartbeat: { type: string, nullable: true }
        latest_triggerer_heartbeat: { type: string, nullable: true }
        latest_dag_processor_heartbeat: { type: string, nullable: true }

    HealthInfo:
      type: object
      properties:
        metadatabase: { $ref: "#/components/schemas/ComponentHealth" }
        scheduler: { $ref: "#/components/schemas/ComponentHealth" }
        triggerer: { $ref: "#/components/schemas/ComponentHealth" }
        dag_processor: { $ref: "#/components/schemas/ComponentHealth" }

    ExecutorInfo:
      type: object
      properties:
        pod_dispatch_enabled: { type: boolean }
        task_namespace: { type: string }
        agent_control_plane_addr: { type: string }
        execution_modes: { type: array, items: { type: string } }

    VersionInfo:
      type: object
      properties:
        version: { type: string }
        git_version: { type: string }

    DAGUpdate:
      type: object
      properties:
        is_paused: { type: boolean }

    DAGRun:
      type: object
      properties:
        dag_id: { type: string }
        dag_run_id: { type: string }
        logical_date: { type: string, format: date-time }
        data_interval_start: { type: string, format: date-time, nullable: true }
        data_interval_end: { type: string, format: date-time, nullable: true }
        queued_at: { type: string, format: date-time }
        start_date: { type: string, format: date-time, nullable: true }
        end_date: { type: string, format: date-time, nullable: true }
        state:
          type: string
          enum: [queued, running, success, failed]
        run_type:
          type: string
          enum: [scheduled, manual, backfill, dataset_triggered]
        conf: { type: object, additionalProperties: true }
        note: { type: string, nullable: true }

    DAGRunCollection:
      type: object
      properties:
        dag_runs:
          type: array
          items: { $ref: "#/components/schemas/DAGRun" }
        total_entries: { type: integer }

    DAGRunCreate:
      type: object
      properties:
        dag_run_id: { type: string }
        logical_date: { type: string, format: date-time }
        conf: { type: object, additionalProperties: true }
        note: { type: string }

    TaskInstance:
      type: object
      properties:
        dag_id: { type: string }
        dag_run_id: { type: string }
        task_id: { type: string }
        map_index: { type: integer, default: -1 }
        try_number: { type: integer }
        state:
          type: string
          nullable: true
          enum: [scheduled, queued, running, success, failed, skipped, upstream_failed, up_for_retry, none]
        operator: { type: string }
        start_date: { type: string, format: date-time, nullable: true }
        end_date: { type: string, format: date-time, nullable: true }
        duration: { type: number, nullable: true }
        hostname: { type: string }
        pool: { type: string }
        max_tries: { type: integer }
        failure_reason:
          type: string
          nullable: true
          description: >-
            Leoflow extension (not part of the Airflow API). A short,
            human-readable cause for a terminal failure, recorded by whichever
            component observed it: the task's own report, the reconciler reading
            the pod (image pull, OOM, exit code), a reaper declaring the pod or
            agent lost, or the agent's classification of a failure that happened
            before it could register. It answers "why did this fail?" for an
            attempt that streamed no logs because its agent never started. Null
            when no cause was observed. Best-effort and diagnostic: it carries a
            classification, never a credential or a raw internal error.
          example: >-
            the control plane rejected this pod's projected ServiceAccount
            token; check the control plane's RBAC for tokenreviews and the
            configured token audience.

    TaskInstanceCollection:
      type: object
      properties:
        task_instances:
          type: array
          items: { $ref: "#/components/schemas/TaskInstance" }
        total_entries: { type: integer }

    ClearTaskInstancesRequest:
      type: object
      properties:
        task_ids:
          type: array
          items: { type: string }
        dag_run_id: { type: string }
        only_failed:
          type: boolean
          default: true
          description: >-
            Restrict the clear to task instances in a failed state, matching
            Apache Airflow. false widens it to every task instance named by the
            request, including ones that succeeded.
        only_running:
          type: boolean
          default: false
          description: >-
            Not supported. Sending true is refused with 400. leoflow has no path
            that clears a running task instance (Apache Airflow sets it to
            RESTARTING and kills it), and honoring the only_failed=false that
            Airflow requires alongside it would widen the clear to every task
            instance named by the request, including ones that succeeded.
        reset_dag_runs:
          type: boolean
          default: true
          description: >-
            Re-open the run so the scheduler looks at it again. Without it a
            terminal run stays terminal and the cleared task instance is never
            scheduled.
        run_on_latest_version:
          type: boolean
          default: false
          description: >-
            Which version the re-run executes. false (the default, matching Apache
            Airflow) keeps the version the run was created with, so the re-run
            executes the image that produced the original attempt. true re-binds
            the run to the DAG's current registered version, so the re-run picks up
            the newest image and config — use it to re-run a failed task against a
            fix without creating a new run.
        dry_run:
          type: boolean
          default: true
          description: >-
            Return the task instances that WOULD be cleared, without clearing
            them. Defaults to true, matching Apache Airflow, so a request that
            names no flags never destroys state. Send false to execute.
        include_upstream: { type: boolean, default: false }
        include_downstream: { type: boolean, default: false }
        include_past: { type: boolean, default: false }
        include_future: { type: boolean, default: false }

    XComEntry:
      type: object
      properties:
        key: { type: string }
        value: {}
        timestamp: { type: string, format: date-time }
        dag_id: { type: string }
        task_id: { type: string }
        dag_run_id: { type: string }
