diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c5f8e587ddbdc..48167eef64b79 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -783,6 +783,7 @@ repos:
^airflow-core/src/airflow/ui/openapi-gen/|
^airflow-core/src/airflow/ui/pnpm-lock\.yaml$|
^providers/common/ai/src/airflow/providers/common/ai/plugins/www/pnpm-lock\.yaml$|
+ ^providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/pnpm-lock\.yaml$|
^airflow-core/src/airflow/ui/public/i18n/locales/de/README\.md$|
^airflow-core/src/airflow/ui/src/i18n/config\.ts$|
^\.agents/skills/airflow-translations/|
diff --git a/providers/common/ai/.pre-commit-config.yaml b/providers/common/ai/.pre-commit-config.yaml
index 9676e56363da3..3a60087a14e71 100644
--- a/providers/common/ai/.pre-commit-config.yaml
+++ b/providers/common/ai/.pre-commit-config.yaml
@@ -43,7 +43,10 @@ repos:
name: Compile Common AI provider assets
language: node
stages: ['pre-commit', 'manual']
- files: ^src/airflow/providers/common/ai/plugins/www/
+ files: |
+ (?x)
+ ^src/airflow/providers/common/ai/plugins/www/|
+ ^src/airflow/providers/common/ai/plugins/ai_trace_www/
entry: ../../../scripts/ci/prek/compile_provider_assets.py ai
pass_filenames: false
additional_dependencies: ['pnpm@10.25.0']
diff --git a/providers/common/ai/hatch_build.py b/providers/common/ai/hatch_build.py
index cd7dc153f3467..eba04574faffd 100644
--- a/providers/common/ai/hatch_build.py
+++ b/providers/common/ai/hatch_build.py
@@ -47,10 +47,12 @@ def clean(self, directory: str, versions: Iterable[str]) -> None:
work_dir = Path(self.root)
log.warning("Cleaning generated files in directory: %s", work_dir)
ai_package_src = work_dir / "src" / "airflow" / "providers" / "common" / "ai"
- ai_ui_path = ai_package_src / "plugins" / "www"
- self.clean_dir(ai_ui_path / ".pnpm-store")
- self.clean_dir(ai_ui_path / "dist")
- self.clean_dir(ai_ui_path / "node_modules")
+ # common.ai ships two plugin bundles (HITL Review + AI Trace); clean both.
+ for bundle in ("www", "ai_trace_www"):
+ ai_ui_path = ai_package_src / "plugins" / bundle
+ self.clean_dir(ai_ui_path / ".pnpm-store")
+ self.clean_dir(ai_ui_path / "dist")
+ self.clean_dir(ai_ui_path / "node_modules")
(work_dir / "www-hash.txt").unlink(missing_ok=True)
def get_version_api(self) -> dict[str, Callable[..., str]]:
diff --git a/providers/common/ai/provider.yaml b/providers/common/ai/provider.yaml
index e5c26e60a55a9..c36065a72ab93 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -80,6 +80,8 @@ hooks:
plugins:
- name: hitl_review
plugin-class: airflow.providers.common.ai.plugins.hitl_review.HITLReviewPlugin
+ - name: ai_trace
+ plugin-class: airflow.providers.common.ai.plugins.ai_trace.AITracePlugin
config:
@@ -140,6 +142,46 @@ config:
type: boolean
example: "False"
default: "False"
+ trace_store_path:
+ description: |
+ ObjectStorage URI for the backend-free trace store -- local dev and
+ quick testing without Langfuse or any OTLP backend. When set, GenAI
+ spans from ``AgentOperator`` / ``@task.agent`` / ``@task.llm`` are
+ written as standard OTLP JSON lines (one file per task-instance try)
+ under this path, and the "AI Trace" UI reads them back directly.
+ Requires neither ``[traces] otel_on`` nor ``otel_export_enabled``.
+ Any scheme supported by ``airflow.sdk.ObjectStoragePath`` is accepted
+ (``file://``, ``s3://``, ``gs://``, ...); the API server needs read
+ access to the same path. Files are plain OTLP JSON, so a collector's
+ ``otlpjsonfilereceiver`` can replay them into a real backend later.
+ Takes precedence over the Langfuse read path when set. Content
+ capture is still gated by ``capture_content``. Costs shown in the
+ UI are read-time estimates from the ``genai-prices`` library's
+ bundled price data (no runtime price fetch) -- approximate, not
+ billing data; unknown or self-hosted models show no cost. No
+ retention is applied; clean the path yourself.
+
+ Spike-status: proof of concept, not a stable layout.
+ version_added: 0.5.0
+ type: string
+ example: "file:///tmp/airflow_ai_traces"
+ default: ""
+ trace_backend_conn_id:
+ description: |
+ Airflow connection used by the "AI Trace" task instance panel to read
+ a trace back from your tracing backend (Langfuse today) once
+ ``otel_export_enabled`` has emitted it. The panel resolves a task
+ instance's trace_id from its own span context, so no data is written
+ anywhere new; this connection is read-only credentials for looking a
+ trace back up. Connection ``host`` is the backend's base URL,
+ ``login``/``password`` are its API key pair.
+
+ Spike-status: this is a proof of concept, not a stable API. The
+ panel only supports Langfuse today.
+ version_added: 0.5.0
+ type: string
+ example: "langfuse_default"
+ default: "langfuse_default"
connection-types:
diff --git a/providers/common/ai/pyproject.toml b/providers/common/ai/pyproject.toml
index e6e2926d921dd..dd16166a370b8 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -183,6 +183,7 @@ provider_info = "airflow.providers.common.ai.get_provider_info:get_provider_info
[project.entry-points."airflow.plugins"]
hitl_review = "airflow.providers.common.ai.plugins.hitl_review:HITLReviewPlugin"
+ai_trace = "airflow.providers.common.ai.plugins.ai_trace:AITracePlugin"
[tool.hatch.version]
path = "src/airflow/providers/common/ai/__init__.py"
@@ -199,6 +200,8 @@ exclude = [
"src/airflow/providers/__init__.py",
"src/airflow/providers/common/ai/plugins/www/.pnpm-store",
"src/airflow/providers/common/ai/plugins/www/node_modules",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/.pnpm-store",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/node_modules",
]
[tool.hatch.build.targets.custom]
@@ -206,16 +209,20 @@ path = "./hatch_build.py"
artifacts = [
"src/airflow/providers/common/ai/plugins/www/dist",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/dist",
]
[tool.hatch.build.targets.wheel]
packages = ['src/airflow']
artifacts = [
"src/airflow/providers/common/ai/plugins/www/dist",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/dist",
]
exclude = [
"src/airflow/__init__.py",
"src/airflow/providers/__init__.py",
"src/airflow/providers/common/ai/plugins/www/.pnpm-store",
"src/airflow/providers/common/ai/plugins/www/node_modules",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/.pnpm-store",
+ "src/airflow/providers/common/ai/plugins/ai_trace_www/node_modules",
]
diff --git a/providers/common/ai/src/airflow/providers/common/ai/_otlp_json.py b/providers/common/ai/src/airflow/providers/common/ai/_otlp_json.py
new file mode 100644
index 0000000000000..6343cf0047536
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/_otlp_json.py
@@ -0,0 +1,172 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Minimal OTLP/JSON span encoder for the ``[common.ai] trace_store_path`` store.
+
+Writes one OTLP ``TracesData`` JSON object per line, following the OTLP
+JSON-Protobuf encoding rules (https://opentelemetry.io/docs/specs/otlp/):
+trace/span ids hex-encoded, 64-bit integers as decimal strings, enums as
+integers, camelCase field names. That makes the files replayable into any real
+backend later via an OpenTelemetry Collector's ``otlpjsonfilereceiver``.
+
+This is vendored rather than depended on: the upstream
+``opentelemetry-exporter-otlp-json-file`` exporter (0.64b0, the only release)
+is uninstallable from PyPI -- its ``opentelemetry-proto-json`` dependency was
+never published (verified 2026-07-04). Swap this module for the official
+``FileSpanExporter(stream=...)`` once a fixed upstream release exists.
+
+Only imported when the trace store is configured, so the OpenTelemetry SDK
+import below never taxes the tracing-off path.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+from collections.abc import Mapping, Sequence
+from typing import IO, TYPE_CHECKING, Any
+
+from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
+from opentelemetry.trace import StatusCode
+
+if TYPE_CHECKING:
+ from opentelemetry.sdk.trace import ReadableSpan
+
+log = logging.getLogger(__name__)
+
+# SDK enum value -> OTLP wire value (OTLP reserves 0 for UNSPECIFIED, so the
+# SDK's INTERNAL=0..CONSUMER=4 shift up by one).
+_OTLP_STATUS = {StatusCode.UNSET: 0, StatusCode.OK: 1, StatusCode.ERROR: 2}
+
+
+def _any_value(value: Any) -> dict[str, Any]:
+ # bool before int: bool is an int subclass.
+ if isinstance(value, bool):
+ return {"boolValue": value}
+ if isinstance(value, int):
+ return {"intValue": str(value)}
+ if isinstance(value, float):
+ return {"doubleValue": value}
+ if isinstance(value, (bytes, bytearray)):
+ return {"bytesValue": base64.b64encode(value).decode()}
+ if isinstance(value, Mapping):
+ return {"kvlistValue": {"values": _key_values(value)}}
+ if isinstance(value, Sequence) and not isinstance(value, str):
+ return {"arrayValue": {"values": [_any_value(v) for v in value]}}
+ return {"stringValue": str(value)}
+
+
+def _key_values(attributes: Mapping[str, Any]) -> list[dict[str, Any]]:
+ return [{"key": k, "value": _any_value(v)} for k, v in attributes.items()]
+
+
+def _encode_span(span: ReadableSpan) -> dict[str, Any]:
+ ctx = span.get_span_context()
+ out: dict[str, Any] = {
+ "traceId": format(ctx.trace_id if ctx else 0, "032x"),
+ "spanId": format(ctx.span_id if ctx else 0, "016x"),
+ "name": span.name,
+ "kind": (span.kind.value if span.kind is not None else 0) + 1,
+ "startTimeUnixNano": str(span.start_time or 0),
+ "endTimeUnixNano": str(span.end_time or 0),
+ "attributes": _key_values(span.attributes or {}),
+ "status": {},
+ }
+ if span.parent is not None:
+ out["parentSpanId"] = format(span.parent.span_id, "016x")
+ if span.events:
+ # Exception details land here via Span.record_exception.
+ out["events"] = [
+ {
+ "timeUnixNano": str(event.timestamp),
+ "name": event.name,
+ "attributes": _key_values(event.attributes or {}),
+ }
+ for event in span.events
+ ]
+ if span.status is not None:
+ code = _OTLP_STATUS.get(span.status.status_code, 0)
+ if code:
+ out["status"]["code"] = code
+ if span.status.description:
+ out["status"]["message"] = span.status.description
+ return out
+
+
+def encode_traces_data(spans: Sequence[ReadableSpan]) -> dict[str, Any]:
+ """Encode finished spans as one OTLP ``TracesData`` object, grouped by resource/scope."""
+ grouped: dict[Any, dict[Any, list[dict[str, Any]]]] = {}
+ resources: dict[Any, Any] = {}
+ scopes: dict[Any, Any] = {}
+ for span in spans:
+ resource, scope = span.resource, span.instrumentation_scope
+ resources[id(resource)] = resource
+ scopes[id(scope)] = scope
+ grouped.setdefault(id(resource), {}).setdefault(id(scope), []).append(_encode_span(span))
+ return {
+ "resourceSpans": [
+ {
+ "resource": {"attributes": _key_values(getattr(resources[rid], "attributes", None) or {})},
+ "scopeSpans": [
+ {
+ "scope": {
+ "name": getattr(scopes[sid], "name", "") or "",
+ **(
+ {"version": scopes[sid].version}
+ if getattr(scopes[sid], "version", None)
+ else {}
+ ),
+ },
+ "spans": scope_spans,
+ }
+ for sid, scope_spans in by_scope.items()
+ ],
+ }
+ for rid, by_scope in grouped.items()
+ ]
+ }
+
+
+class OTLPJsonStreamExporter(SpanExporter):
+ """
+ Write each export batch as one OTLP/JSON ``TracesData`` line to a text stream.
+
+ Flushes after every line so spans are readable while the task still runs.
+ On ``file://`` this makes each finished span immediately visible; object
+ stores (s3/gcs) buffer locally and upload on close, so their content lands
+ at task-process exit -- acceptable for a dev/test store.
+ """
+
+ def __init__(self, stream: IO[str]) -> None:
+ self._stream = stream
+
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+ try:
+ self._stream.write(json.dumps(encode_traces_data(spans), separators=(",", ":")) + "\n")
+ self._stream.flush()
+ except Exception:
+ log.exception("Failed to write %d span(s) to the common.ai trace store", len(spans))
+ return SpanExportResult.FAILURE
+ return SpanExportResult.SUCCESS
+
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
+ self._stream.flush()
+ return True
+
+ def shutdown(self) -> None:
+ self._stream.flush()
diff --git a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index 89d96737d94fa..cc7e40c520ee7 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -85,7 +85,11 @@ def get_provider_info():
{
"name": "hitl_review",
"plugin-class": "airflow.providers.common.ai.plugins.hitl_review.HITLReviewPlugin",
- }
+ },
+ {
+ "name": "ai_trace",
+ "plugin-class": "airflow.providers.common.ai.plugins.ai_trace.AITracePlugin",
+ },
],
"config": {
"common.ai": {
@@ -112,6 +116,20 @@ def get_provider_info():
"example": "False",
"default": "False",
},
+ "trace_store_path": {
+ "description": "ObjectStorage URI for the backend-free trace store -- local dev and\nquick testing without Langfuse or any OTLP backend. When set, GenAI\nspans from ``AgentOperator`` / ``@task.agent`` / ``@task.llm`` are\nwritten as standard OTLP JSON lines (one file per task-instance try)\nunder this path, and the \"AI Trace\" UI reads them back directly.\nRequires neither ``[traces] otel_on`` nor ``otel_export_enabled``.\nAny scheme supported by ``airflow.sdk.ObjectStoragePath`` is accepted\n(``file://``, ``s3://``, ``gs://``, ...); the API server needs read\naccess to the same path. Files are plain OTLP JSON, so a collector's\n``otlpjsonfilereceiver`` can replay them into a real backend later.\nTakes precedence over the Langfuse read path when set. Content\ncapture is still gated by ``capture_content``. Costs shown in the\nUI are read-time estimates from the ``genai-prices`` library's\nbundled price data (no runtime price fetch) -- approximate, not\nbilling data; unknown or self-hosted models show no cost. No\nretention is applied; clean the path yourself.\n\nSpike-status: proof of concept, not a stable layout.\n",
+ "version_added": "0.5.0",
+ "type": "string",
+ "example": "file:///tmp/airflow_ai_traces",
+ "default": "",
+ },
+ "trace_backend_conn_id": {
+ "description": "Airflow connection used by the \"AI Trace\" task instance panel to read\na trace back from your tracing backend (Langfuse today) once\n``otel_export_enabled`` has emitted it. The panel resolves a task\ninstance's trace_id from its own span context, so no data is written\nanywhere new; this connection is read-only credentials for looking a\ntrace back up. Connection ``host`` is the backend's base URL,\n``login``/``password`` are its API key pair.\n\nSpike-status: this is a proof of concept, not a stable API. The\npanel only supports Langfuse today.\n",
+ "version_added": "0.5.0",
+ "type": "string",
+ "example": "langfuse_default",
+ "default": "langfuse_default",
+ },
},
}
},
diff --git a/providers/common/ai/src/airflow/providers/common/ai/observability.py b/providers/common/ai/src/airflow/providers/common/ai/observability.py
index 08a59c40595f8..f787508c1e2bc 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/observability.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/observability.py
@@ -31,13 +31,18 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Literal
+import atexit
+import logging
+import threading
+from typing import TYPE_CHECKING, Any, Literal
from airflow.providers.common.compat.sdk import conf
if TYPE_CHECKING:
from pydantic_ai.models.instrumented import InstrumentationSettings
+log = logging.getLogger(__name__)
+
SECTION = "common.ai"
# OTel GenAI semantic-convention format version. Pinned so a change in the
@@ -57,6 +62,10 @@ def _capture_content() -> bool:
return conf.getboolean(SECTION, "capture_content", fallback=False)
+def _trace_store_path() -> str:
+ return conf.get(SECTION, "trace_store_path", fallback="")
+
+
def _live_tracer_provider():
"""
Return the worker's configured SDK ``TracerProvider``, or ``None``.
@@ -78,21 +87,128 @@ def _live_tracer_provider():
return provider if isinstance(provider, TracerProvider) else None
+# One store stream/provider per task instance try, shared across every agent
+# built in that task; closed at process exit (task processes are per-task).
+# The lock makes the cache's check-then-create atomic: two agents built
+# concurrently for the same TI must not both open the file with mode "w" (the
+# second would truncate the first's spans) -- exactly one provider/stream is
+# created and cached per key.
+_STORE_PROVIDERS: dict[tuple[Any, ...], Any] = {}
+_STORE_PROVIDERS_LOCK = threading.Lock()
+_ATEXIT_REGISTERED = False
+
+
+def _close_store_providers() -> None:
+ for provider, stream in _STORE_PROVIDERS.values():
+ try:
+ provider.shutdown()
+ except Exception:
+ log.exception("Failed to shut down trace-store provider")
+ try:
+ stream.close()
+ except Exception:
+ log.exception("Failed to close trace-store stream")
+ _STORE_PROVIDERS.clear()
+
+
+def _store_tracer_provider():
+ """
+ Build (or reuse) a private ``TracerProvider`` writing to the trace store.
+
+ Backend-free local-dev mode: when ``[common.ai] trace_store_path`` is set,
+ GenAI spans are written as standard OTLP JSON lines (the small
+ spec-compliant encoder in ``_otlp_json.py`` over an ``ObjectStoragePath``
+ stream) under a task-instance-keyed layout::
+
+ {store} / {dag_id} / {run_id} / {task_id} / {map_index} / {try_number}.jsonl
+
+ The path IS the correlation, so this works with core tracing
+ (``[traces] otel_on``) completely off -- no collector, no backend. When
+ core tracing is on, the ambient task-span context still parents these
+ spans, so trace ids line up with ``context_carrier`` too. Files are plain
+ OTLP JSON: a collector's ``otlpjsonfilereceiver`` can replay them into any
+ real backend later.
+ """
+ store = _trace_store_path()
+ if not store:
+ return None
+ try:
+ from opentelemetry.sdk.trace import TracerProvider
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+ from opentelemetry.sdk.trace.sampling import ALWAYS_ON
+
+ from airflow.providers.common.ai._otlp_json import OTLPJsonStreamExporter
+ from airflow.sdk import ObjectStoragePath, get_current_context
+ except ImportError:
+ log.warning(
+ "[common.ai] trace_store_path is set but the OpenTelemetry SDK is not "
+ "installed; install 'opentelemetry-sdk' to enable the trace store."
+ )
+ return None
+ try:
+ ti = get_current_context()["ti"]
+ except Exception:
+ # Not inside a task (e.g. parsing) -- nothing to key the store by.
+ return None
+
+ key = (ti.dag_id, ti.run_id, ti.task_id, ti.map_index, ti.try_number)
+ # Lock the whole check-then-create so concurrent agent builds for the same
+ # TI can't both open the file "w" (truncating each other) -- the second
+ # caller sees the first's cached provider and reuses its stream.
+ with _STORE_PROVIDERS_LOCK:
+ cached = _STORE_PROVIDERS.get(key)
+ if cached is not None:
+ return cached[0]
+
+ path = (
+ ObjectStoragePath(store)
+ / ti.dag_id
+ / ti.run_id
+ / ti.task_id
+ / str(ti.map_index)
+ / f"{ti.try_number}.jsonl"
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ stream = path.open("w")
+ # ALWAYS_ON, not the default ParentBased: the task runner attaches the
+ # TI's context_carrier traceparent even when core tracing is off, and
+ # that carrier has trace-flags 00 (unsampled). A parent-based sampler
+ # would inherit the "don't sample" decision and record nothing.
+ provider = TracerProvider(sampler=ALWAYS_ON)
+ # Simple (per-span, synchronous) rather than Batch: nothing buffers in
+ # the processor, so a task that dies mid-run has already written every
+ # span that finished.
+ provider.add_span_processor(SimpleSpanProcessor(OTLPJsonStreamExporter(stream)))
+
+ global _ATEXIT_REGISTERED
+ if not _ATEXIT_REGISTERED:
+ atexit.register(_close_store_providers)
+ _ATEXIT_REGISTERED = True
+ _STORE_PROVIDERS[key] = (provider, stream)
+ log.info("common.ai trace store active: writing GenAI spans to %s", path)
+ return provider
+
+
def genai_instrumentation_settings() -> InstrumentationSettings | None:
"""
Build pydantic-ai ``InstrumentationSettings`` for an agent run.
- Returns ``None`` (leave the agent un-instrumented, zero overhead) when
- export is disabled or no live OTLP ``TracerProvider`` is configured in this
- worker process. ``include_content`` is off by default so prompts,
- completions, and tool IO are never emitted unless explicitly opted in via
+ Two sources, in order: the ObjectStorage trace store (when
+ ``[common.ai] trace_store_path`` is set -- zero-infra local dev, needs no
+ core tracing), else Airflow's live OTLP ``TracerProvider`` (when
+ ``otel_export_enabled`` and core tracing are on). Returns ``None`` (agent
+ left un-instrumented, zero overhead) when neither applies.
+ ``include_content`` is off by default so prompts, completions, and tool IO
+ are never emitted unless explicitly opted in via
``[common.ai] capture_content``.
"""
- if not _otel_export_enabled():
- return None
- provider = _live_tracer_provider()
+ provider = _store_tracer_provider()
if provider is None:
- return None
+ if not _otel_export_enabled():
+ return None
+ provider = _live_tracer_provider()
+ if provider is None:
+ return None
# Imported here, not at module top: this module is imported by the hook on
# every agent build, but the ``instrumented`` submodule is only needed when
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace.py b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace.py
new file mode 100644
index 0000000000000..3146e4bf10d31
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace.py
@@ -0,0 +1,1197 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import base64
+import json
+import logging
+import re
+from datetime import datetime, timezone
+from typing import Annotated, Any
+from urllib.parse import urlparse
+
+from airflow.plugins_manager import AirflowPlugin
+from airflow.providers.common.compat.sdk import conf
+from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_1_PLUS
+
+_PLUGIN_PREFIX = "/ai-trace"
+SECTION = "common.ai"
+log = logging.getLogger(__name__)
+
+# W3C trace ids are 32 lowercase-hex chars. Validated before a caller-supplied
+# trace_id reaches a LIKE reverse-lookup or a file content scan.
+_TRACE_ID_RE = re.compile(r"[0-9a-f]{32}")
+
+
+def _get_base_url_path(path: str) -> str:
+ """Construct URL path with webserver base_url prefix for non-root deployments."""
+ base_url = conf.get("api", "base_url", fallback="/")
+ if base_url.startswith(("http://", "https://")):
+ base_path = urlparse(base_url).path
+ else:
+ base_path = base_url
+ base_path = base_path.rstrip("/")
+ return base_path + path
+
+
+def _get_bundle_url() -> str:
+ """
+ Return bundle URL for the React plugin.
+
+ Uses an absolute URL when api.base_url is a full URL so the bundle loads
+ correctly in Vite dev mode, where import() resolves relative to the script
+ origin (5173) rather than the document origin (28080).
+ """
+ path = _get_base_url_path(f"{_PLUGIN_PREFIX}/static/main.umd.cjs")
+ base_url = conf.get("api", "base_url", fallback="/")
+ if base_url.startswith(("http://", "https://")):
+ parsed = urlparse(base_url)
+ return f"{parsed.scheme}://{parsed.netloc}" + path
+ return path
+
+
+def _trace_backend_conn_id() -> str:
+ return conf.get(SECTION, "trace_backend_conn_id", fallback="langfuse_default")
+
+
+if AIRFLOW_V_3_1_PLUS:
+ import mimetypes
+ from pathlib import Path
+
+ import requests
+ from fastapi import Depends, FastAPI, HTTPException, Query
+ from fastapi.staticfiles import StaticFiles
+ from sqlalchemy import String as SAString, case as sa_case, cast as sa_cast, func as sa_func, select
+ from sqlalchemy.orm import Session
+
+ from airflow.api_fastapi.app import get_auth_manager
+ from airflow.api_fastapi.auth.managers.models.resource_details import DagAccessEntity
+ from airflow.api_fastapi.core_api.security import GetUserDep, requires_access_dag
+ from airflow.models.taskinstance import TaskInstance as TI
+ from airflow.providers.common.compat.sdk import BaseHook, ObjectStoragePath
+ from airflow.utils.session import create_session
+
+ try:
+ # Ships transitively with pydantic-ai-slim (a hard dependency of this
+ # provider); guarded so a missing or API-shifted genai-prices can only
+ # ever null the cost column, never break the panel.
+ from genai_prices import Usage as GenAIUsage, calc_price as _calc_genai_price
+ except ImportError:
+ _calc_genai_price = None # type: ignore[assignment]
+
+ # common.ai operator classes that carry a GenAI trace worth listing. TI.operator
+ # stores the concrete class name (task.task_type), so subclasses (LLMSQLQueryOperator
+ # etc.) need listing explicitly -- there's no shared marker interface to filter on.
+ _AGENT_OPERATOR_NAMES = (
+ "AgentOperator",
+ "LLMOperator",
+ "LLMSQLQueryOperator",
+ "LLMSchemaCompareOperator",
+ "LLMBranchOperator",
+ "LLMFileAnalysisOperator",
+ "LlamaIndexRetrievalOperator",
+ "LlamaIndexEmbeddingOperator",
+ "DocumentLoaderOperator",
+ )
+
+ def _get_session():
+ with create_session(scoped=False) as session:
+ yield session
+
+ SessionDep = Annotated[Session, Depends(_get_session)]
+
+ def _get_map_index(q: str = Query("-1", alias="map_index")) -> int:
+ """Parse map_index query; use -1 when placeholder unreplaced (e.g. ``{MAP_INDEX}``) or invalid."""
+ try:
+ return int(q)
+ except (ValueError, TypeError):
+ return -1
+
+ MapIndexDep = Annotated[int, Depends(_get_map_index)]
+
+ def _trace_id_from_context_carrier(carrier: dict[str, Any] | None) -> str | None:
+ """
+ Recover the W3C trace_id from a TaskInstance's stored traceparent.
+
+ ``observability.py`` reuses the worker's live ``TracerProvider`` without
+ forcing new span/trace ids, so pydantic-ai's GenAI spans nest under the
+ task's own span and inherit its trace_id -- there is no separate
+ correlation step (no XCom push, no ``override_ids``) needed to find
+ "this task instance's trace": it's the same trace_id the task span
+ itself carries in ``context_carrier``.
+ """
+ if not carrier:
+ return None
+ traceparent = carrier.get("traceparent")
+ if not traceparent:
+ return None
+ parts = traceparent.split("-")
+ if len(parts) != 4:
+ return None
+ return parts[1]
+
+ def _fetch_langfuse_trace(*, host: str, token: str, trace_id: str) -> dict[str, Any]:
+ resp = requests.get(
+ f"{host}/api/public/traces/{trace_id}",
+ headers={"Authorization": f"Basic {token}"},
+ timeout=10.0,
+ )
+ if resp.status_code == 404:
+ raise HTTPException(
+ status_code=404,
+ detail="Trace not found in Langfuse yet -- ingestion can lag a few seconds after the task finishes.",
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+ def _extract_text(messages: Any) -> str | None:
+ """
+ Flatten an OTel GenAI ``gen_ai.{input,output}.messages`` array to plain text.
+
+ Verified 2026-07-02 against a live Langfuse response: Langfuse maps this
+ attribute (JSON array of ``{role, parts: [{type, content}]}``) directly
+ onto the observation's ``input``/``output`` field, unparsed -- so this
+ reads Langfuse's field, not a raw OTel attribute.
+ """
+ if not isinstance(messages, list):
+ return None
+ chunks = []
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ role = message.get("role", "?")
+ texts = [
+ str(part["content"])
+ for part in message.get("parts") or []
+ if isinstance(part, dict) and part.get("type") == "text" and part.get("content")
+ ]
+ if texts:
+ chunks.append(f"{role}: " + " ".join(texts))
+ return "\n".join(chunks) if chunks else None
+
+ def _text_or_raw(value: Any) -> str | None:
+ # Never silently blank content that exists: when the message array
+ # carries no plain-text parts (tool-call parts, images, structured
+ # output), fall back to the raw JSON instead of dropping it.
+ text = _extract_text(value)
+ if text is None and value is not None:
+ return json.dumps(value, indent=2)
+ return text
+
+ def _messages_of(value: Any) -> list[dict[str, str]]:
+ # Role-ordered conversation: one entry per message, text parts joined,
+ # non-text parts kept as JSON so tool-call/structured content is never
+ # silently dropped.
+ out: list[dict[str, str]] = []
+ if not isinstance(value, list):
+ return out
+ for m in value:
+ if not isinstance(m, dict):
+ continue
+ chunks: list[str] = []
+ for p in m.get("parts") or []:
+ if isinstance(p, dict) and p.get("type") == "text" and p.get("content"):
+ chunks.append(str(p["content"]))
+ elif p is not None:
+ chunks.append(json.dumps(p, indent=2))
+ out.append({"role": str(m.get("role", "?")), "content": "\n".join(chunks)})
+ return out
+
+ def _normalize_trace(raw: dict[str, Any], *, host: str, trace_id: str) -> dict[str, Any]:
+ """
+ Reduce a Langfuse trace payload to the fields the panel renders.
+
+ Field names verified 2026-07-02 against a live trace ingested through
+ the real OTLP -> collector -> Langfuse path: ``observations[].type`` is
+ one of ``GENERATION`` (``model``/``usage.total`` present directly) or
+ ``TOOL`` (Langfuse's own classification of an ``execute_tool *`` OTel
+ GenAI span, not ``SPAN`` as originally guessed before this was checked
+ live). ``input``/``output`` on either type are populated ONLY when the
+ run had ``[common.ai] capture_content = True`` -- same gate as the rest
+ of this provider's content-capture story, not a separate opt-in here.
+ """
+ observations = raw.get("observations") or []
+ generations = [o for o in observations if o.get("type") == "GENERATION"]
+ total_tokens = sum((g.get("usage") or {}).get("total") or 0 for g in generations)
+ # Failure surfaced at the top of the card, not buried in a tree node.
+ first_error = next(
+ (
+ o.get("statusMessage") or "ERROR"
+ for o in observations
+ if o.get("level") == "ERROR" or o.get("statusMessage")
+ ),
+ None,
+ )
+ model = next((g.get("model") for g in generations if g.get("model")), None)
+ generation = generations[0] if generations else None
+
+ conversation = (
+ _messages_of(generation.get("input")) + _messages_of(generation.get("output"))
+ if generation
+ else []
+ )
+
+ return {
+ "trace_id": trace_id,
+ "timestamp": raw.get("timestamp"),
+ "latency": raw.get("latency"),
+ "cost": raw.get("totalCost"),
+ "model": model,
+ "total_tokens": total_tokens or None,
+ "prompt": _text_or_raw(generation.get("input")) if generation else None,
+ "completion": _text_or_raw(generation.get("output")) if generation else None,
+ "conversation": conversation,
+ "observation_count": len(observations),
+ "error": first_error,
+ "metadata": raw.get("metadata"),
+ # IO-stripped observation tree for the modal: the detail payload
+ # carries structure + per-node summary numbers only; input/output
+ # (the heavy chat-message arrays and tool payloads) are lazy-fetched
+ # per node on expand via /observations/{id}.
+ "observations": [
+ {
+ "id": o.get("id"),
+ "parent_observation_id": o.get("parentObservationId"),
+ "type": o.get("type"),
+ "name": o.get("name"),
+ "start_time": o.get("startTime"),
+ "latency": o.get("latency"),
+ "model": o.get("model"),
+ "input_tokens": (o.get("usage") or {}).get("input"),
+ "output_tokens": (o.get("usage") or {}).get("output"),
+ "total_tokens": (o.get("usage") or {}).get("total"),
+ "cost": o.get("calculatedTotalCost") or o.get("totalCost"),
+ "level": o.get("level"),
+ "status_message": o.get("statusMessage"),
+ }
+ for o in observations
+ ],
+ "langfuse_url": f"{host}/trace/{trace_id}",
+ }
+
+ def _fetch_langfuse_trace_list(
+ *, host: str, token: str, from_timestamp: str | None, limit: int
+ ) -> dict[str, dict[str, Any]]:
+ """
+ One batched Langfuse list call to enrich many rows, not one GET per row.
+
+ Langfuse's public API has no "give me exactly these trace ids" filter,
+ so this pulls the most recent traces since ``from_timestamp`` (the
+ oldest start_date among the Airflow rows being enriched) and matches by
+ id client-side. Best-effort: a trace outside this window, or not yet
+ ingested, just won't be in the returned map.
+ """
+ params: dict[str, Any] = {"limit": limit}
+ if from_timestamp:
+ params["fromTimestamp"] = from_timestamp
+ resp = requests.get(
+ f"{host}/api/public/traces",
+ headers={"Authorization": f"Basic {token}"},
+ params=params,
+ timeout=10.0,
+ )
+ resp.raise_for_status()
+ return {row["id"]: row for row in resp.json().get("data", [])}
+
+ def _summarize_list_row(raw: dict[str, Any] | None) -> dict[str, Any]:
+ """Reduce one Langfuse list-row to the fields the list view renders."""
+ if raw is None:
+ return {
+ "model": None,
+ "total_tokens": None,
+ "input_tokens": None,
+ "output_tokens": None,
+ "cost": None,
+ "latency": None,
+ "input_preview": None,
+ }
+ attrs = (raw.get("metadata") or {}).get("attributes") or {}
+ input_tokens = attrs.get("gen_ai.usage.input_tokens")
+ output_tokens = attrs.get("gen_ai.usage.output_tokens")
+ total_tokens = None
+ if input_tokens is not None or output_tokens is not None:
+ total_tokens = int(input_tokens or 0) + int(output_tokens or 0)
+ # Trace-level input is the OTel message array when capture_content was
+ # on for the run, absent otherwise -- flatten to a short plain-text
+ # preview for the list row (full text lives in the detail modal).
+ preview = _extract_text(raw.get("input"))
+ if preview and len(preview) > 200:
+ preview = preview[:200] + "…"
+ return {
+ "model": attrs.get("gen_ai.response.model"),
+ "total_tokens": total_tokens,
+ "input_tokens": int(input_tokens) if input_tokens is not None else None,
+ "output_tokens": int(output_tokens) if output_tokens is not None else None,
+ "cost": raw.get("totalCost"),
+ "latency": raw.get("latency"),
+ "input_preview": preview,
+ }
+
+ # ---- ObjectStorage trace store (backend-free mode) -------------------
+ # When [common.ai] trace_store_path is set, the emit side (observability.py)
+ # writes each task try's GenAI spans as OTLP JSON lines under
+ # {store}/{dag_id}/{run_id}/{task_id}/{map_index}/{try_number}.jsonl.
+ # The readers below parse those files back into the exact same response
+ # shapes the Langfuse path produces, so the frontend needs no mode switch
+ # beyond a nullable external link. Store mode takes precedence over the
+ # Langfuse connection and needs neither a backend nor core tracing.
+
+ def _trace_store_path() -> str:
+ return conf.get(SECTION, "trace_store_path", fallback="")
+
+ def _is_safe_segment(seg: str) -> bool:
+ """
+ Report whether ``seg`` is a single, contained path component.
+
+ ObjectStoragePath does NOT normalize ``..`` and treats a leading ``/``
+ as absolute (verified: ``store / ".." / "x"`` escapes the root and
+ ``store / "/etc"`` resolves to ``file:///etc``), so a task-instance
+ coordinate that reaches a path join must be rejected first -- otherwise
+ an attacker-controlled ``dag_id``/``run_id``/``task_id`` reads any file
+ the API server can. Allow only what real Airflow ids contain; reject
+ empties, separators, ``.``/``..``, and NUL.
+ """
+ return bool(seg) and seg not in (".", "..") and not (set(seg) & {"/", "\\", "\x00"})
+
+ def _reject_unsafe_ti_coords(dag_id: str, run_id: str, task_id: str) -> None:
+ if not all(_is_safe_segment(s) for s in (dag_id, run_id, task_id)):
+ raise HTTPException(status_code=400, detail="Invalid task instance identifier.")
+
+ def _otlp_value(value: dict[str, Any]) -> Any:
+ """Decode one OTLP JSON ``AnyValue`` (``{"stringValue": ...}`` etc.) to a plain value."""
+ if "stringValue" in value:
+ return value["stringValue"]
+ if "intValue" in value:
+ # The OTLP JSON encoding carries int64 as a decimal string.
+ return int(value["intValue"])
+ if "doubleValue" in value:
+ return value["doubleValue"]
+ if "boolValue" in value:
+ return value["boolValue"]
+ if "arrayValue" in value:
+ return [_otlp_value(v) for v in value["arrayValue"].get("values") or []]
+ if "kvlistValue" in value:
+ return _otlp_attrs(value["kvlistValue"].get("values"))
+ return value.get("bytesValue")
+
+ def _otlp_attrs(kvs: list[dict[str, Any]] | None) -> dict[str, Any]:
+ return {kv["key"]: _otlp_value(kv.get("value") or {}) for kv in kvs or [] if "key" in kv}
+
+ def _parse_otlp_lines(text: str) -> list[dict[str, Any]]:
+ """Flatten OTLP JSON lines (one ``TracesData`` per line) to a list of raw spans."""
+ spans: list[dict[str, Any]] = []
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ try:
+ data = json.loads(line)
+ except ValueError:
+ continue
+ for resource_spans in data.get("resourceSpans") or []:
+ for scope_spans in resource_spans.get("scopeSpans") or []:
+ spans.extend(scope_spans.get("spans") or [])
+ return spans
+
+ def _ns_to_iso(ns: Any) -> str | None:
+ try:
+ return datetime.fromtimestamp(int(ns) / 1e9, tz=timezone.utc).isoformat()
+ except (TypeError, ValueError):
+ return None
+
+ def _json_maybe(value: Any) -> Any:
+ # gen_ai.* content attributes (messages, tool arguments/results) are
+ # JSON-encoded strings on the wire; decode so the UI renders structure,
+ # but pass through verbatim when a producer wrote plain text.
+ if isinstance(value, str):
+ try:
+ return json.loads(value)
+ except ValueError:
+ return value
+ return value
+
+ _GENERATION_OPS = frozenset({"chat", "text_completion", "generate_content"})
+
+ def _estimate_generation_cost(
+ *, model: Any, provider: Any, input_tokens: Any, output_tokens: Any, start_ns: Any
+ ) -> float | None:
+ """
+ Best-effort USD estimate for one generation via genai-prices.
+
+ Store mode has no ingest step to price tokens the way Langfuse does,
+ so the read side estimates at render time instead: always current with
+ the library's bundled price data (no runtime price fetch), retroactive
+ for files recorded before that data existed, and never persisted --
+ the span files stay pure OTLP. The span timestamp keys historic
+ pricing, so replayed old files price at the rates of their day.
+ These are estimates, not billing data; unknown or self-hosted models
+ return None and render as an em dash.
+ """
+ if _calc_genai_price is None or not model or not (input_tokens or output_tokens):
+ return None
+ try:
+ when = datetime.fromtimestamp(int(start_ns) / 1e9, tz=timezone.utc) if start_ns else None
+ except (TypeError, ValueError):
+ when = None
+ try:
+ price = _calc_genai_price(
+ GenAIUsage(input_tokens=int(input_tokens or 0), output_tokens=int(output_tokens or 0)),
+ model_ref=str(model),
+ provider_id=str(provider) if provider else None,
+ genai_request_timestamp=when,
+ )
+ except Exception:
+ # LookupError for unknown models is the expected common case;
+ # anything else (upstream API drift) equally must not break the
+ # panel -- cost is an annotation, never load-bearing.
+ return None
+ return float(price.total_price)
+
+ def _store_span_node(span: dict[str, Any]) -> dict[str, Any]:
+ """
+ Map one raw OTLP span to the observation-node shape the modal tree renders.
+
+ Type comes from ``gen_ai.operation.name`` (the OTel GenAI semconv field
+ pydantic-ai emits): a model call is GENERATION, ``execute_tool`` is
+ TOOL, anything else (agent run wrapper span) is SPAN -- mirroring how
+ Langfuse classifies the same spans at ingest. Unlike the Langfuse path,
+ ``input``/``output`` are INLINE on the node: the whole file was just
+ read anyway, so there is nothing to lazy-fetch. Keys are always present
+ (null when a span has no IO) -- the frontend uses key-presence to skip
+ its lazy /observations fetch in store mode.
+ """
+ attrs = _otlp_attrs(span.get("attributes"))
+ op = attrs.get("gen_ai.operation.name")
+ obs_type = "GENERATION" if op in _GENERATION_OPS else "TOOL" if op == "execute_tool" else "SPAN"
+ status = span.get("status") or {}
+ is_error = status.get("code") in (2, "2", "STATUS_CODE_ERROR")
+ try:
+ latency = (int(span["endTimeUnixNano"]) - int(span["startTimeUnixNano"])) / 1e9
+ except (KeyError, TypeError, ValueError):
+ latency = None
+ input_tokens = attrs.get("gen_ai.usage.input_tokens")
+ output_tokens = attrs.get("gen_ai.usage.output_tokens")
+ model = attrs.get("gen_ai.response.model") or attrs.get("gen_ai.request.model")
+ if obs_type == "GENERATION":
+ io_in = _json_maybe(attrs.get("gen_ai.input.messages"))
+ io_out = _json_maybe(attrs.get("gen_ai.output.messages"))
+ cost = _estimate_generation_cost(
+ model=model,
+ provider=attrs.get("gen_ai.provider.name") or attrs.get("gen_ai.system"),
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ start_ns=span.get("startTimeUnixNano"),
+ )
+ elif obs_type == "TOOL":
+ io_in = _json_maybe(attrs.get("gen_ai.tool.call.arguments"))
+ io_out = _json_maybe(attrs.get("gen_ai.tool.call.result"))
+ cost = None
+ else:
+ io_in = io_out = None
+ cost = None
+ return {
+ "id": span.get("spanId"),
+ "parent_observation_id": span.get("parentSpanId") or None,
+ "type": obs_type,
+ "name": span.get("name"),
+ "start_time": _ns_to_iso(span.get("startTimeUnixNano")),
+ "latency": latency,
+ "model": model,
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "total_tokens": ((input_tokens or 0) + (output_tokens or 0)) or None,
+ "cost": cost,
+ "level": "ERROR" if is_error else None,
+ "status_message": (status.get("message") or "ERROR") if is_error else None,
+ "input": io_in,
+ "output": io_out,
+ }
+
+ def _normalize_store_trace(raw_spans: list[dict[str, Any]]) -> dict[str, Any]:
+ """
+ Build the trace-detail payload (same shape as ``_normalize_trace``) from raw spans.
+
+ ``metadata`` is null (there is no ingest step to stamp it) and
+ ``langfuse_url`` is null -- the frontend hides the external link.
+ ``cost`` is a read-time genai-prices estimate summed over generations
+ (None when every model is unknown). With core tracing off each agent
+ run roots its own
+ trace id, so one task try's file can legitimately hold several trace
+ ids; the file (one task try) is the trace unit here, and the reported
+ trace_id is the first root span's.
+ """
+ nodes = sorted((_store_span_node(s) for s in raw_spans), key=lambda n: n["start_time"] or "")
+ generations = [n for n in nodes if n["type"] == "GENERATION"]
+ total_tokens = sum(n["total_tokens"] or 0 for n in generations)
+ model = next((n["model"] for n in generations if n["model"]), None)
+ generation = generations[0] if generations else None
+ conversation = (
+ _messages_of(generation["input"]) + _messages_of(generation["output"]) if generation else []
+ )
+ first_error = next((n["status_message"] or "ERROR" for n in nodes if n["level"] == "ERROR"), None)
+ starts = [int(s["startTimeUnixNano"]) for s in raw_spans if s.get("startTimeUnixNano")]
+ ends = [int(s["endTimeUnixNano"]) for s in raw_spans if s.get("endTimeUnixNano")]
+ latency = (max(ends) - min(starts)) / 1e9 if starts and ends else None
+ node_ids = {n["id"] for n in nodes}
+ trace_id = next(
+ (
+ s.get("traceId")
+ for s in raw_spans
+ if not s.get("parentSpanId") or s.get("parentSpanId") not in node_ids
+ ),
+ raw_spans[0].get("traceId") if raw_spans else None,
+ )
+ return {
+ "trace_id": trace_id,
+ "timestamp": _ns_to_iso(min(starts)) if starts else None,
+ "latency": latency,
+ "cost": sum(n["cost"] for n in generations if n["cost"]) or None,
+ "model": model,
+ "total_tokens": total_tokens or None,
+ "prompt": _text_or_raw(generation["input"]) if generation else None,
+ "completion": _text_or_raw(generation["output"]) if generation else None,
+ "conversation": conversation,
+ "observation_count": len(nodes),
+ "error": first_error,
+ "metadata": None,
+ "observations": nodes,
+ "langfuse_url": None,
+ }
+
+ def _latest_try_file(store: Any, dag_id: str, run_id: str, task_id: str, map_index: int) -> Any | None:
+ """Return the highest-try ``{try}.jsonl`` for one TI, or None if nothing was stored."""
+ # Defense in depth: endpoints reject unsafe query params up front, but
+ # this also guards DB-sourced coordinates (list enrichment) so a single
+ # poisoned row can never escape the store root via a path join.
+ if not all(_is_safe_segment(s) for s in (dag_id, run_id, task_id)):
+ return None
+ ti_dir = store / dag_id / run_id / task_id / str(map_index)
+ try:
+ files = [p for p in ti_dir.iterdir() if p.name.endswith(".jsonl")]
+ except (FileNotFoundError, NotADirectoryError, OSError):
+ return None
+
+ def try_number(p: Any) -> int:
+ try:
+ return int(p.stem)
+ except ValueError:
+ return -1
+
+ return max(files, key=try_number, default=None)
+
+ # (path, mtime)-keyed list-enrichment cache: span files are append-only per
+ # try, so an unchanged mtime means the parsed summary is still valid.
+ _STORE_SUMMARY_CACHE: dict[tuple[str, float], dict[str, Any]] = {}
+
+ def _store_row_summary(
+ store: Any, dag_id: str, run_id: str, task_id: str, map_index: int
+ ) -> dict[str, Any] | None:
+ """List-row enrichment from the store; None when the TI has no stored spans."""
+ path = _latest_try_file(store, dag_id, run_id, task_id, map_index)
+ if path is None:
+ return None
+ try:
+ mtime = float(path.stat().st_mtime)
+ except (OSError, TypeError, ValueError):
+ mtime = 0.0
+ key = (str(path), mtime)
+ cached = _STORE_SUMMARY_CACHE.get(key)
+ if cached is not None:
+ return dict(cached)
+ spans = _parse_otlp_lines(path.read_text())
+ if not spans:
+ return None
+ trace = _normalize_store_trace(spans)
+ generations = [n for n in trace["observations"] if n["type"] == "GENERATION"]
+ preview = trace["prompt"]
+ if preview and len(preview) > 200:
+ preview = preview[:200] + "…"
+ summary = {
+ "trace_id": trace["trace_id"],
+ "model": trace["model"],
+ "total_tokens": trace["total_tokens"],
+ "input_tokens": sum(n["input_tokens"] or 0 for n in generations) or None,
+ "output_tokens": sum(n["output_tokens"] or 0 for n in generations) or None,
+ "cost": trace["cost"],
+ "latency": trace["latency"],
+ "input_preview": preview,
+ }
+ if len(_STORE_SUMMARY_CACHE) > 512:
+ _STORE_SUMMARY_CACHE.clear()
+ _STORE_SUMMARY_CACHE[key] = summary
+ return dict(summary)
+
+ def _store_find_trace_files(store: Any, trace_id: str) -> list[Any]:
+ """
+ Full-store scan for span files containing ``trace_id``.
+
+ Fallback for /trace/{id} when the context_carrier reverse-lookup finds
+ nothing (pure store mode runs with core tracing off, so TIs carry no
+ traceparent). Walks the fixed dag/run/task/map_index layout and
+ substring-checks each latest-try file -- O(store) reads, which is the
+ honest cost of a bare-id lookup without an index; fine at the local-dev
+ scale this mode targets.
+ """
+
+ def subdirs(p: Any) -> list[Any]:
+ try:
+ return [c for c in p.iterdir() if c.is_dir()]
+ except (FileNotFoundError, NotADirectoryError, OSError):
+ return []
+
+ matches = []
+ for dag_dir in subdirs(store):
+ for run_dir in subdirs(dag_dir):
+ for task_dir in subdirs(run_dir):
+ for mi_dir in subdirs(task_dir):
+ latest = max(
+ (p for p in mi_dir.iterdir() if p.name.endswith(".jsonl")),
+ key=lambda p: int(p.stem) if p.stem.isdigit() else -1,
+ default=None,
+ )
+ if latest is not None and trace_id in latest.read_text():
+ matches.append(latest)
+ return matches
+
+ def _airflow_ref_from_path(path: Any) -> dict[str, Any]:
+ """Recover TI coordinates from a span file's {dag}/{run}/{task}/{map_index}/{try}.jsonl path."""
+ mi_dir = path.parent
+ task_dir = mi_dir.parent
+ run_dir = task_dir.parent
+ try:
+ map_index = int(mi_dir.name)
+ except ValueError:
+ map_index = -1
+ return {
+ "dag_id": run_dir.parent.name,
+ "run_id": run_dir.name,
+ "task_id": task_dir.name,
+ "map_index": map_index,
+ }
+
+ def _require_dag_access(user: Any, dag_id: str) -> None:
+ """
+ Raise 403 unless ``user`` may read ``dag_id``.
+
+ The bare-id endpoints resolve a trace/observation to its owning task
+ instance and then gate on access to *that* DAG -- so a user authorized
+ for one DAG cannot read another DAG's captured prompts, completions, or
+ tool IO. Uses the same ``get_authorized_dag_ids`` mechanism the list
+ endpoints scope with (the module-level ``get_auth_manager()`` singleton,
+ since ``request.app.state`` is not populated in a mounted sub-app).
+ """
+ if dag_id not in get_auth_manager().get_authorized_dag_ids(user=user, method="GET"):
+ raise HTTPException(status_code=403, detail="Not authorized to read this trace's DAG.")
+
+ ai_trace_app = FastAPI(
+ title="AI Trace",
+ description=(
+ "Read-side panel resolving a task instance's GenAI trace via the "
+ "configured trace backend (Langfuse today) and linking out to it."
+ ),
+ )
+
+ @ai_trace_app.get("/health")
+ async def health() -> dict[str, str]:
+ """Liveness check."""
+ return {"status": "ok"}
+
+ @ai_trace_app.get(
+ "/trace-summary",
+ dependencies=[
+ Depends(requires_access_dag(method="GET", access_entity=DagAccessEntity.TASK_INSTANCE))
+ ],
+ )
+ def trace_summary(
+ db: SessionDep,
+ dag_id: str,
+ task_id: str,
+ run_id: str,
+ map_index: MapIndexDep,
+ ) -> dict[str, Any]:
+ """Resolve this task instance's trace_id and return a normalized summary."""
+ _reject_unsafe_ti_coords(dag_id, run_id, task_id)
+ store = _trace_store_path()
+ if store:
+ # The store layout is keyed by TI coordinates, so the path IS the
+ # correlation -- no context_carrier (and hence no core tracing)
+ # required, and no trace backend touched.
+ path = _latest_try_file(ObjectStoragePath(store), dag_id, run_id, task_id, map_index)
+ spans = _parse_otlp_lines(path.read_text()) if path is not None else []
+ if not spans:
+ raise HTTPException(
+ status_code=404,
+ detail=(
+ "No stored trace for this task instance yet. The trace store only has "
+ "spans after an instrumented agent has run (and, on object stores, "
+ "after the task process exits)."
+ ),
+ )
+ return _normalize_store_trace(spans)
+
+ carrier = db.scalar(
+ select(TI.context_carrier).where(
+ TI.dag_id == dag_id,
+ TI.run_id == run_id,
+ TI.task_id == task_id,
+ TI.map_index == map_index,
+ )
+ )
+ trace_id = _trace_id_from_context_carrier(carrier)
+ if trace_id is None:
+ raise HTTPException(
+ status_code=404,
+ detail=(
+ "No trace context on this task instance yet. Core tracing "
+ "([traces] otel_on) may be off, or the task hasn't started."
+ ),
+ )
+
+ conn_id = _trace_backend_conn_id()
+ conn = BaseHook.get_connection(conn_id)
+ host = (conn.host or "").rstrip("/")
+ if not host:
+ raise HTTPException(status_code=500, detail=f"Connection {conn_id!r} has no host set.")
+ token = base64.b64encode(f"{conn.login}:{conn.password}".encode()).decode()
+
+ raw = _fetch_langfuse_trace(host=host, token=token, trace_id=trace_id)
+ return _normalize_trace(raw, host=host, trace_id=trace_id)
+
+ @ai_trace_app.get(
+ "/trace/{trace_id}",
+ dependencies=[Depends(requires_access_dag(method="GET"))],
+ )
+ def trace_by_id(db: SessionDep, user: GetUserDep, trace_id: str) -> dict[str, Any]:
+ """
+ Direct trace-by-id lookup, bypassing the task-instance correlation.
+
+ The trace is resolved to its owning task instance first, and access is
+ then gated on *that* DAG (``_require_dag_access``): a user authorized
+ for one DAG cannot read another's captured prompts/completions/tool IO.
+ A trace that resolves to no task instance returns 404 -- there is no DAG
+ to authorize against, so it is treated as not found rather than served
+ under the any-DAG floor.
+
+ ``trace_id`` is validated as a 32-char lowercase-hex W3C trace id
+ before use: it flows into a ``LIKE %{trace_id}%`` reverse-lookup (a
+ bare ``%`` would otherwise match an arbitrary TI) and into a substring
+ content scan of stored files.
+ """
+ if not _TRACE_ID_RE.fullmatch(trace_id):
+ raise HTTPException(status_code=400, detail="Invalid trace id (expected 32 hex characters).")
+ store = _trace_store_path()
+ if store:
+ root = ObjectStoragePath(store)
+ # Cheap path first: when core tracing was on, the TI's traceparent
+ # points straight at the file; otherwise fall back to scanning.
+ row = db.execute(
+ select(TI.dag_id, TI.run_id, TI.task_id, TI.map_index)
+ .where(sa_cast(TI.context_carrier, SAString).like(f"%{trace_id}%"))
+ .limit(1)
+ ).first()
+ files = []
+ if row:
+ f = _latest_try_file(root, row.dag_id, row.run_id, row.task_id, row.map_index)
+ files = [f] if f is not None else []
+ if not files:
+ files = _store_find_trace_files(root, trace_id)
+ if not files:
+ raise HTTPException(status_code=404, detail="No stored trace with this id.")
+ ref = (
+ {
+ "dag_id": row.dag_id,
+ "run_id": row.run_id,
+ "task_id": row.task_id,
+ "map_index": row.map_index,
+ }
+ if row
+ else _airflow_ref_from_path(files[0])
+ )
+ # Authorize the owning DAG BEFORE reading any span content.
+ _require_dag_access(user, ref["dag_id"])
+ # A single task-try file can hold several trace ids (one per agent
+ # run when core tracing is off), so keep ONLY the requested trace's
+ # spans -- normalizing the whole file together would mix an
+ # unrelated agent's prompt/model into this response and expose every
+ # root in one tree.
+ spans = [
+ span
+ for f in files
+ for span in _parse_otlp_lines(f.read_text())
+ if span.get("traceId") == trace_id
+ ]
+ if not spans:
+ raise HTTPException(status_code=404, detail="No stored trace with this id.")
+ out = _normalize_store_trace(spans)
+ out["trace_id"] = trace_id
+ out["airflow_ref"] = ref
+ return out
+
+ # Langfuse mode: resolve the owning TI FIRST and authorize its DAG
+ # before fetching any content from the backend. The trace_id is embedded
+ # in the stored traceparent, so a substring match on the serialized
+ # context_carrier finds it exactly (W3C trace ids are 32-hex and
+ # collision-free in practice). POC-grade; an indexed column would
+ # replace this LIKE if the plugin ever grows up.
+ row = db.execute(
+ select(TI.dag_id, TI.run_id, TI.task_id, TI.map_index)
+ .where(sa_cast(TI.context_carrier, SAString).like(f"%{trace_id}%"))
+ .limit(1)
+ ).first()
+ if row is None:
+ raise HTTPException(
+ status_code=404,
+ detail="No task instance owns this trace id, so it cannot be authorized.",
+ )
+ _require_dag_access(user, row.dag_id)
+
+ conn_id = _trace_backend_conn_id()
+ conn = BaseHook.get_connection(conn_id)
+ host = (conn.host or "").rstrip("/")
+ if not host:
+ raise HTTPException(status_code=500, detail=f"Connection {conn_id!r} has no host set.")
+ token = base64.b64encode(f"{conn.login}:{conn.password}".encode()).decode()
+ raw = _fetch_langfuse_trace(host=host, token=token, trace_id=trace_id)
+ out = _normalize_trace(raw, host=host, trace_id=trace_id)
+ out["airflow_ref"] = {
+ "dag_id": row.dag_id,
+ "run_id": row.run_id,
+ "task_id": row.task_id,
+ "map_index": row.map_index,
+ }
+ return out
+
+ @ai_trace_app.get(
+ "/observations/{obs_id}",
+ dependencies=[Depends(requires_access_dag(method="GET"))],
+ )
+ def observation_io(db: SessionDep, user: GetUserDep, obs_id: str) -> dict[str, Any]:
+ """
+ Lazy-fetch a single observation's input/output for the modal tree.
+
+ The trace-detail payload deliberately strips per-observation IO (full
+ chat-message arrays and tool payloads are the heavy part); the frontend
+ calls this only when a node is first expanded -- the same pattern
+ Langfuse's own peek view uses (includeIO: false on the tree query).
+
+ The observation is resolved to its trace and then to the owning task
+ instance, and access is gated on that DAG -- so this cannot be used to
+ read another DAG's tool IO by guessing observation ids. Content is
+ returned only after the access check passes. Never called in store mode
+ -- store observations carry their IO inline (the whole file is read
+ anyway) and the frontend skips the lazy fetch when the keys are present.
+ """
+ if _trace_store_path():
+ raise HTTPException(
+ status_code=404, detail="Observation IO is inlined in the trace payload in store mode."
+ )
+ conn_id = _trace_backend_conn_id()
+ conn = BaseHook.get_connection(conn_id)
+ host = (conn.host or "").rstrip("/")
+ if not host:
+ raise HTTPException(status_code=500, detail=f"Connection {conn_id!r} has no host set.")
+ token = base64.b64encode(f"{conn.login}:{conn.password}".encode()).decode()
+ resp = requests.get(
+ f"{host}/api/public/observations/{obs_id}",
+ headers={"Authorization": f"Basic {token}"},
+ timeout=10.0,
+ )
+ if resp.status_code == 404:
+ raise HTTPException(status_code=404, detail="observation not found")
+ resp.raise_for_status()
+ obs = resp.json()
+ # Resolve observation -> trace -> owning TI -> DAG, and authorize before
+ # returning any IO. An observation whose trace owns no task instance is
+ # treated as not found (nothing to authorize against).
+ obs_trace_id = obs.get("traceId")
+ row = (
+ db.execute(
+ select(TI.dag_id)
+ .where(sa_cast(TI.context_carrier, SAString).like(f"%{obs_trace_id}%"))
+ .limit(1)
+ ).first()
+ if obs_trace_id
+ else None
+ )
+ if row is None:
+ raise HTTPException(
+ status_code=404,
+ detail="No task instance owns this observation's trace, so it cannot be authorized.",
+ )
+ _require_dag_access(user, row.dag_id)
+ return {
+ "id": obs.get("id"),
+ "input": obs.get("input"),
+ "output": obs.get("output"),
+ "model_parameters": obs.get("modelParameters"),
+ }
+
+ @ai_trace_app.get(
+ "/agents",
+ dependencies=[Depends(requires_access_dag(method="GET"))],
+ )
+ def list_agents(db: SessionDep, user: GetUserDep) -> dict[str, Any]:
+ """
+ Aggregate every agent in the deployment.
+
+ One row per (dag, task) that ran a common.ai operator, RBAC-scoped to
+ the user's readable DAGs. Airflow-only on purpose -- run counts,
+ failure counts, and recency come straight from the TI table with no
+ tracing prerequisite, so an agent that ran with tracing off still
+ shows up in the inventory.
+ """
+ readable_dag_ids = get_auth_manager().get_authorized_dag_ids(user=user, method="GET")
+ rows = db.execute(
+ select(
+ TI.dag_id,
+ TI.task_id,
+ TI.operator,
+ sa_func.count().label("runs"),
+ sa_func.sum(sa_case((TI.state == "failed", 1), else_=0)).label("failed"),
+ sa_func.max(TI.start_date).label("last_run"),
+ )
+ .where(TI.operator.in_(_AGENT_OPERATOR_NAMES))
+ .where(TI.dag_id.in_(readable_dag_ids))
+ .group_by(TI.dag_id, TI.task_id, TI.operator)
+ .order_by(sa_func.max(TI.start_date).desc())
+ ).all()
+ return {
+ "items": [
+ {
+ "dag_id": r.dag_id,
+ "task_id": r.task_id,
+ "operator": r.operator,
+ "runs": r.runs,
+ "failed": int(r.failed or 0),
+ "last_run": r.last_run.isoformat() if r.last_run else None,
+ }
+ for r in rows
+ ]
+ }
+
+ @ai_trace_app.get(
+ "/traces",
+ dependencies=[Depends(requires_access_dag(method="GET"))],
+ )
+ def list_traces(
+ db: SessionDep,
+ user: GetUserDep,
+ dag_id: str | None = Query(None),
+ task_id: str | None = Query(None),
+ since: str | None = Query(None),
+ state: str | None = Query(None),
+ min_latency: float | None = Query(None, description="seconds; drops rows without enrichment"),
+ min_cost: float | None = Query(None, description="USD; drops rows without enrichment"),
+ limit: int = Query(50, le=200),
+ ) -> dict[str, Any]:
+ """
+ List recent agent task instances, enriched with trace summaries.
+
+ Airflow supplies the rows (which tasks, dag/run/state); Langfuse is hit
+ exactly ONCE for the whole list (not once per row) to enrich them with
+ model/tokens/cost/latency/score-count, via its list-traces endpoint
+ rather than N per-trace GETs. If that call fails, the list still
+ renders with Airflow-only columns -- enrichment is best-effort, the
+ list itself is not. Each row also links to the existing
+ per-task-instance panel for the full detail.
+
+ ``dag_id``, when passed, scopes both the query AND the RBAC check:
+ ``requires_access_dag`` reads ``request.query_params.get("dag_id")``
+ itself, so passing it here checks access to that specific DAG rather
+ than the generic "any DAG" floor -- this is what the ``dag``-destination
+ tab uses; the ``nav``-destination list omits it and gets the floor.
+
+ Uses ``get_auth_manager()`` (the module-level singleton) rather than
+ the ``ReadableDagsFilterDep``/``AuthManagerDep`` dependency chain --
+ that chain resolves the auth manager from ``request.app.state``, which
+ is only populated on the main api-server app. A plugin's ``fastapi_apps``
+ entry is a separately mounted sub-app, so ``request.app`` there is this
+ module's ``ai_trace_app``, not the main app; ``ReadableDagsFilterDep``
+ raises ``KeyError: 'auth_manager'`` in that context.
+ """
+ readable_dag_ids = get_auth_manager().get_authorized_dag_ids(user=user, method="GET")
+ store = _trace_store_path()
+ query = (
+ select(
+ TI.dag_id,
+ TI.run_id,
+ TI.task_id,
+ TI.map_index,
+ TI.state,
+ TI.start_date,
+ TI.operator,
+ TI.context_carrier,
+ )
+ .where(TI.operator.in_(_AGENT_OPERATOR_NAMES))
+ .where(TI.dag_id.in_(readable_dag_ids))
+ .order_by(TI.start_date.desc())
+ .limit(limit)
+ )
+ if not store:
+ # A row without a traceparent can't be correlated to a Langfuse
+ # trace, so it has nothing to show. In store mode the FILE is the
+ # correlation and pure store mode runs with core tracing off, so
+ # every carrier would be empty -- the filter would hide everything.
+ query = query.where(TI.context_carrier.is_not(None))
+ if dag_id:
+ query = query.where(TI.dag_id == dag_id)
+ if task_id:
+ query = query.where(TI.task_id == task_id)
+ if state:
+ query = query.where(TI.state == state)
+ if since:
+ try:
+ since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
+ except ValueError:
+ raise HTTPException(status_code=400, detail="invalid `since` (must be ISO8601)") from None
+ query = query.where(TI.start_date >= since_dt)
+ rows = db.execute(query).all()
+
+ langfuse_by_id: dict[str, dict[str, Any]] = {}
+ if not store:
+ # Store mode never touches the trace-backend connection -- part of
+ # the zero-infra contract (no Langfuse conn need exist at all).
+ conn_id = _trace_backend_conn_id()
+ conn = BaseHook.get_connection(conn_id)
+ host = (conn.host or "").rstrip("/")
+ if host and rows:
+ token = base64.b64encode(f"{conn.login}:{conn.password}".encode()).decode()
+ min_start = min((r.start_date for r in rows if r.start_date), default=None)
+ try:
+ langfuse_by_id = _fetch_langfuse_trace_list(
+ host=host,
+ token=token,
+ from_timestamp=min_start.isoformat() if min_start else None,
+ limit=max(limit, 50),
+ )
+ except requests.RequestException:
+ log.warning(
+ "Could not enrich AI Trace list from connection %r; showing Airflow-only rows.",
+ conn_id,
+ )
+
+ store_root = ObjectStoragePath(store) if store else None
+ items = []
+ for r in rows:
+ trace_id = _trace_id_from_context_carrier(r.context_carrier)
+ if store_root is not None:
+ row_summary = _store_row_summary(store_root, r.dag_id, r.run_id, r.task_id, r.map_index)
+ if row_summary is not None:
+ # With core tracing off the carrier is empty; the file's own
+ # trace id is what makes the row clickable (modal deep link).
+ file_trace_id = row_summary.pop("trace_id", None)
+ trace_id = trace_id or file_trace_id
+ summary = row_summary
+ else:
+ summary = _summarize_list_row(None)
+ else:
+ summary = _summarize_list_row(langfuse_by_id.get(trace_id) if trace_id else None)
+ items.append(
+ {
+ "dag_id": r.dag_id,
+ "run_id": r.run_id,
+ "task_id": r.task_id,
+ "map_index": r.map_index,
+ "state": r.state,
+ "start_date": r.start_date.isoformat() if r.start_date else None,
+ "operator": r.operator,
+ "trace_id": trace_id,
+ **summary,
+ }
+ )
+ # Enrichment-derived filters run post-merge: latency/cost only exist on
+ # the Langfuse side, so rows without enrichment are dropped when one of
+ # these is set (documented on the params; asking "cost > X" about a row
+ # with unknown cost has no honest answer other than exclusion).
+ if min_latency is not None:
+ items = [i for i in items if i["latency"] is not None and i["latency"] >= min_latency]
+ if min_cost is not None:
+ items = [i for i in items if i["cost"] is not None and i["cost"] >= min_cost]
+ return {"items": items}
+
+ @ai_trace_app.middleware("http")
+ async def _revalidate_static(request, call_next): # type: ignore[no-untyped-def]
+ """
+ Serve the bundle with ``Cache-Control: no-cache``.
+
+ The bundle URL is unversioned (no content hash), so browsers heuristically
+ disk-cache it and keep rendering a stale UI after a plugin upgrade.
+ ``no-cache`` forces revalidation, which Starlette's StaticFiles answers
+ with a cheap 304 via ETag/Last-Modified when the file hasn't changed.
+ """
+ response = await call_next(request)
+ # request.url.path is the FULL path even inside a mounted sub-app
+ # (e.g. /ai-trace/static/main.umd.cjs), so match on the segment.
+ if "/static/" in request.url.path:
+ response.headers["Cache-Control"] = "no-cache"
+ return response
+
+ # Ensure proper MIME types for plugin bundle (FastAPI serves .cjs as text/plain by default)
+ mimetypes.add_type("application/javascript", ".cjs")
+
+ _WWW_DIR = Path(__file__).parent / "ai_trace_www"
+ _dist_dir = _WWW_DIR / "dist"
+ if _dist_dir.is_dir():
+ ai_trace_app.mount(
+ "/static",
+ StaticFiles(directory=str(_dist_dir.absolute()), html=True),
+ name="ai_trace_static",
+ )
+
+
+class AITracePlugin(AirflowPlugin):
+ """Register the AI Trace REST API + panel on the Airflow API server."""
+
+ name = "ai_trace"
+ fastapi_apps: list[dict[str, Any]] = []
+ react_apps: list[dict[str, str]] = []
+ if AIRFLOW_V_3_1_PLUS:
+ fastapi_apps = [
+ {
+ "name": "ai-trace",
+ "app": ai_trace_app,
+ "url_prefix": _PLUGIN_PREFIX,
+ }
+ ]
+ react_apps = [
+ {
+ "name": "AI Trace",
+ "bundle_url": _get_bundle_url(),
+ "destination": "task_instance",
+ "url_route": "ai-trace",
+ },
+ {
+ # Same bundle as the task_instance entry -- the component branches
+ # on whether dagId/runId/taskId route params are present (nav has
+ # none) to decide list-view vs single-trace-card.
+ "name": "AI Traces",
+ "bundle_url": _get_bundle_url(),
+ "destination": "nav",
+ "url_route": "ai-traces",
+ },
+ {
+ # Same bundle; dagId present without runId/taskId -> dag-scoped list.
+ # Label matches the nav entry so the feature has ONE name. The
+ # host loader caches the component under reactApp.name, but both
+ # entries resolve the identical bundle, so sharing is benign.
+ "name": "AI Traces",
+ "bundle_url": _get_bundle_url(),
+ "destination": "dag",
+ "url_route": "ai-traces-dag",
+ },
+ ]
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/.gitignore b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/.gitignore
new file mode 100644
index 0000000000000..aa80e7d6f3877
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/.gitignore
@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.pnpm-store
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Coverage
+coverage
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/index.html b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/index.html
new file mode 100644
index 0000000000000..e11095b66a703
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ AI Trace
+
+
+
+
+
+
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/package.json b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/package.json
new file mode 100644
index 0000000000000..5a322635bc786
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "ai-trace",
+ "packageManager": "pnpm@9.14.2",
+ "private": true,
+ "version": "0.0.0",
+ "engines": {
+ "node": ">=22"
+ },
+ "type": "module",
+ "main": "./dist/main.js",
+ "module": "./dist/main.js",
+ "types": "./dist/main.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/main.js",
+ "types": "./dist/main.d.ts"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "dev": "vite --port 5175 --strictPort",
+ "build": "vite build",
+ "build:types": "tsc --p tsconfig.lib.json",
+ "build:lib": "vite build",
+ "lint": "tsc --p tsconfig.app.json --noEmit",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@chakra-ui/react": "^3.34.0",
+ "@emotion/react": "^11.14.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react-swc": "^4.2.3",
+ "typescript": "~5.9.3",
+ "vite": "^8.0.16",
+ "vite-plugin-css-injected-by-js": "^3.5.2",
+ "vite-plugin-dts": "^4.5.4"
+ },
+ "pnpm": {
+ "minimumReleaseAge": 5760,
+ "overrides": {
+ "minimatch@>=10.0.0 <10.2.3": ">=10.2.3",
+ "brace-expansion@>=2.0.0 <2.0.3": ">=2.0.3",
+ "brace-expansion@>=4.0.0 <5.0.5": ">=5.0.5",
+ "picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
+ "yaml@>=1.0.0 <1.10.3": ">=1.10.3",
+ "lodash@>=4.0.0 <=4.17.23": ">=4.18.0",
+ "lodash@<=4.17.23": ">=4.18.0",
+ "vite@>=7.0.0 <=7.3.1": ">=7.3.2",
+ "vite@>=7.1.0 <=7.3.1": ">=7.3.2",
+ "postcss@<8.5.10": ">=8.5.10",
+ "fast-uri@<=3.1.0": ">=3.1.1",
+ "fast-uri@<=3.1.1": ">=3.1.2"
+ }
+ }
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/pnpm-lock.yaml b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/pnpm-lock.yaml
new file mode 100644
index 0000000000000..cf52078275625
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/pnpm-lock.yaml
@@ -0,0 +1,2728 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+overrides:
+ minimatch@>=10.0.0 <10.2.3: '>=10.2.3'
+ brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
+ brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5'
+ picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
+ yaml@>=1.0.0 <1.10.3: '>=1.10.3'
+ lodash@>=4.0.0 <=4.17.23: '>=4.18.0'
+ lodash@<=4.17.23: '>=4.18.0'
+ vite@>=7.0.0 <=7.3.1: '>=7.3.2'
+ vite@>=7.1.0 <=7.3.1: '>=7.3.2'
+ postcss@<8.5.10: '>=8.5.10'
+ fast-uri@<=3.1.0: '>=3.1.1'
+ fast-uri@<=3.1.1: '>=3.1.2'
+
+importers:
+
+ .:
+ dependencies:
+ '@chakra-ui/react':
+ specifier: ^3.34.0
+ version: 3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@emotion/react':
+ specifier: ^11.14.0
+ version: 11.14.0(@types/react@19.2.17)(react@19.2.7)
+ react:
+ specifier: ^19.2.4
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.7(react@19.2.7)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react-swc':
+ specifier: ^4.2.3
+ version: 4.3.1(@swc/helpers@0.5.23)(vite@8.1.3(yaml@2.9.0))
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^8.0.16
+ version: 8.1.3(yaml@2.9.0)
+ vite-plugin-css-injected-by-js:
+ specifier: ^3.5.2
+ version: 3.5.2(vite@8.1.3(yaml@2.9.0))
+ vite-plugin-dts:
+ specifier: ^4.5.4
+ version: 4.5.4(typescript@5.9.3)(vite@8.1.3(yaml@2.9.0))
+
+packages:
+
+ '@ark-ui/react@5.37.2':
+ resolution: {integrity: sha512-Q0R2Ah50kUhup0Ljxg65zGJq5yBV52BLm1coRkjHHid40d1yclaDGfhPL48kcF/xtjAFlGLkL6SiENkGvfh+mw==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@chakra-ui/react@3.36.0':
+ resolution: {integrity: sha512-6AxUbJsC6yyTzPeYL8sxyAL07lflT0NA+S6tcPzEuwdMux+benRMFOpPktnkifWKl/Vq/JD7fhxDyMuDQ4M0gA==}
+ peerDependencies:
+ '@emotion/react': '>=11'
+ react: '>=18'
+ react-dom: '>=18'
+
+ '@emnapi/core@1.11.1':
+ resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
+
+ '@emnapi/runtime@1.11.1':
+ resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
+
+ '@emnapi/wasi-threads@1.2.2':
+ resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
+
+ '@emotion/babel-plugin@11.13.5':
+ resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
+
+ '@emotion/cache@11.14.0':
+ resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
+
+ '@emotion/hash@0.9.2':
+ resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
+
+ '@emotion/is-prop-valid@1.4.0':
+ resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
+
+ '@emotion/memoize@0.9.0':
+ resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
+
+ '@emotion/react@11.14.0':
+ resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@emotion/serialize@1.3.3':
+ resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
+
+ '@emotion/sheet@1.4.0':
+ resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
+
+ '@emotion/unitless@0.10.0':
+ resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
+
+ '@emotion/use-insertion-effect-with-fallbacks@1.2.0':
+ resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@emotion/utils@1.4.2':
+ resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
+
+ '@emotion/weak-memoize@0.4.0':
+ resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
+
+ '@floating-ui/core@1.7.5':
+ resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
+
+ '@floating-ui/dom@1.7.6':
+ resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
+
+ '@floating-ui/utils@0.2.11':
+ resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+
+ '@internationalized/date@3.12.2':
+ resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
+
+ '@internationalized/number@3.6.6':
+ resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@microsoft/api-extractor-model@7.33.8':
+ resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==}
+
+ '@microsoft/api-extractor@7.58.9':
+ resolution: {integrity: sha512-S2UF4yza5GoxCmf7hJQNxJNZN9ltOVuOQv8Dy+Z21aol5ERoBNMdWcQHm4MJMPPItW4H/4rZD906iaf4mUojJA==}
+ hasBin: true
+
+ '@microsoft/tsdoc-config@0.18.1':
+ resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==}
+
+ '@microsoft/tsdoc@0.16.0':
+ resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
+
+ '@napi-rs/wasm-runtime@1.1.6':
+ resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.138.0':
+ resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==}
+
+ '@pandacss/is-valid-prop@1.11.4':
+ resolution: {integrity: sha512-RWxInlS+lGgKiF0fB0HO76vsJFgRvbavm5Z25/GqqN8MPHXYA6n5rZnfdp4itEXy5DJkQ9vt3yrwa2IKiuhtrA==}
+ engines: {node: '>=20'}
+
+ '@rolldown/binding-android-arm64@1.1.4':
+ resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.1.4':
+ resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.1.4':
+ resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.1.4':
+ resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.4':
+ resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.4':
+ resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-musl@1.1.4':
+ resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.4':
+ resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.4':
+ resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-gnu@1.1.4':
+ resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-musl@1.1.4':
+ resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-openharmony-arm64@1.1.4':
+ resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.1.4':
+ resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.4':
+ resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.1.4':
+ resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@rollup/pluginutils@5.4.0':
+ resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rushstack/node-core-library@5.23.1':
+ resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@rushstack/problem-matcher@0.2.1':
+ resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@rushstack/rig-package@0.7.3':
+ resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==}
+
+ '@rushstack/terminal@0.24.0':
+ resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@rushstack/ts-command-line@5.3.10':
+ resolution: {integrity: sha512-fwI076HYknC0IrMXdY6UmjDv+PH7NHhNJX3/pY2UblSE5XrXgndXZPiOe/6ZtuFpn6DvVDVNhtkIzQ+Qu/MhVQ==}
+
+ '@swc/core-darwin-arm64@1.15.43':
+ resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.43':
+ resolution: {integrity: sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.43':
+ resolution: {integrity: sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.43':
+ resolution: {integrity: sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-arm64-musl@1.15.43':
+ resolution: {integrity: sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@swc/core-linux-ppc64-gnu@1.15.43':
+ resolution: {integrity: sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@swc/core-linux-s390x-gnu@1.15.43':
+ resolution: {integrity: sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@swc/core-linux-x64-gnu@1.15.43':
+ resolution: {integrity: sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-linux-x64-musl@1.15.43':
+ resolution: {integrity: sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@swc/core-win32-arm64-msvc@1.15.43':
+ resolution: {integrity: sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.43':
+ resolution: {integrity: sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.43':
+ resolution: {integrity: sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.43':
+ resolution: {integrity: sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/helpers@0.5.23':
+ resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
+
+ '@swc/types@0.1.27':
+ resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
+
+ '@tybys/wasm-util@0.10.3':
+ resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
+
+ '@types/argparse@1.0.38':
+ resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/parse-json@4.0.2':
+ resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.17':
+ resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+
+ '@vitejs/plugin-react-swc@4.3.1':
+ resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: '>=7.3.2'
+
+ '@volar/language-core@2.4.28':
+ resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
+
+ '@volar/source-map@2.4.28':
+ resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
+
+ '@volar/typescript@2.4.28':
+ resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
+
+ '@vue/compiler-core@3.5.39':
+ resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==}
+
+ '@vue/compiler-dom@3.5.39':
+ resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==}
+
+ '@vue/compiler-vue2@2.7.16':
+ resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+ '@vue/language-core@2.2.0':
+ resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/shared@3.5.39':
+ resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==}
+
+ '@zag-js/accordion@1.41.2':
+ resolution: {integrity: sha512-7G//V7svGGT8k5avw7bbQvbRC0Q/9QtX51b4iyAB1alR9E5mFd6Ch8q4njwcXClMQ7xePS3jUfVnzVGiRInEiQ==}
+
+ '@zag-js/anatomy@1.41.2':
+ resolution: {integrity: sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg==}
+
+ '@zag-js/angle-slider@1.41.2':
+ resolution: {integrity: sha512-+7bZHAZx0MEbjTMr2tD+meFJ0EJwFfUEcTqmdLzFGr/ySAMCWlcadDBz+ZmrSn03aKLps8FxliVLzsFJNgUqIQ==}
+
+ '@zag-js/aria-hidden@1.41.2':
+ resolution: {integrity: sha512-qEcYmwlQr3qjA0T/IZ5a/o7fRUxfQ14tXjAFhR3GXCtxBKaqS+wnq/LN09Xw4bin3QWTINU+Z0oXFs9spWtNwA==}
+
+ '@zag-js/async-list@1.41.2':
+ resolution: {integrity: sha512-NZZEIGFdeDp2uHjsLVegLAGJOYGwI9HPJI1V2c/P1TQmfmrfWyWELAvnnW4kWYVUKYD9TxKQkm6LvqpHrQzgfQ==}
+
+ '@zag-js/auto-resize@1.41.2':
+ resolution: {integrity: sha512-CYq+JQ1TTkEiK7OcNcMTS0f4wFvtmManvUltije5o50gDQ7vFYA81oQh4A9pAB1keUi9Zv06CNefFARaTcne5w==}
+
+ '@zag-js/avatar@1.41.2':
+ resolution: {integrity: sha512-+4K0tRtIQysMGuERh5JRmn46uq7gJ6IjJd5DKj74VBtVVY8T6HGk0D0DZxmOIgADHP1qSvowLDoOX8kUyBHarQ==}
+
+ '@zag-js/carousel@1.41.2':
+ resolution: {integrity: sha512-H6sDxyWIzkvtHN4M/BYvFy7pi8j+kLEtfPZvgNl2+gRnfAHVK9/XqpoUsjw1DAo5Fh25pPuaTnh52Kml9OK0iA==}
+
+ '@zag-js/cascade-select@1.41.2':
+ resolution: {integrity: sha512-dBByZmIAJU/B+YIAzczng05lCJXEwEm4df6GmUZtioKHZdeN+WEKUP4qzFDFZdytXympGfJCIBvtgf87gACYVQ==}
+
+ '@zag-js/checkbox@1.41.2':
+ resolution: {integrity: sha512-aQ5dZWHUHRIw4cbLtzrR+dc8M0N6wSiiCml/zbU3ciHOTBXSrs2rKnp/L99xhi97Lj2uCEhpIZ704Ou/MjGuCg==}
+
+ '@zag-js/clipboard@1.41.2':
+ resolution: {integrity: sha512-FM+PNGEeY+YpF1dVr9d8kg3DQM66LRnkR4Mva1oprqnrCXTYWj+k7uig893ubSG2xw1GO5b/WJd27kSmNoKnmw==}
+
+ '@zag-js/collapsible@1.41.2':
+ resolution: {integrity: sha512-uMIp4rqd3iI6VFPMAOcbu9dh9WV4l85nNPYQiBtMKRHgbkfaZdfzF+E+NX3KquIeHW6JiBFUiIyCU0Sf2Cscdw==}
+
+ '@zag-js/collection@1.41.2':
+ resolution: {integrity: sha512-ZZWuvfPZI8ccWd4aLpuU47k1jSc9eO+F3FM3iBJuvgegCH6g3+HDEwGN6wdePHnYfv7zyIKCGKr816zXcIWQbw==}
+
+ '@zag-js/color-picker@1.41.2':
+ resolution: {integrity: sha512-UynyJ/bTBeSlq6ziHr9GVrFycDjVxfLFIb8Aivu/XvihrAAvkhXUxwy+1ax+hj7peGlTY590xoQAvQixV+McBA==}
+
+ '@zag-js/color-utils@1.41.2':
+ resolution: {integrity: sha512-Lsi5c2ztGZqud0oeDtAn3xFhgrYMaeaz1zi4p+mr0zXOuQNuZzfsrJ0stQ45s8L/xT8UJtGhYyEVy1+xjE4ARA==}
+
+ '@zag-js/combobox@1.41.2':
+ resolution: {integrity: sha512-V9jQteyQHs8ZNQx/FcA98P1NVZas/yjiW1V7s4L+h8MnRS3AK97+FAHAT83KCiZ34/vkpLF6ZEHI2zkcQpNXcg==}
+
+ '@zag-js/core@1.41.2':
+ resolution: {integrity: sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw==}
+
+ '@zag-js/date-input@1.41.2':
+ resolution: {integrity: sha512-J8PdpEpM9TBxZBEE8/Nxi39LNJOZY7lilTbgcDABR5uiviZUk5++5GSdUTspcjFUIXx5SvqmdCk2RF7hsUEHVQ==}
+ peerDependencies:
+ '@internationalized/date': '>=3.0.0'
+
+ '@zag-js/date-picker@1.41.2':
+ resolution: {integrity: sha512-j9LaznCV4QCfrq/y/mNAN9XgIRFFg+414TxGT4TK8mfWouIaFvxEoKdGP0l/LL4DFv1NRDaRdSBBcw3eXtMVnA==}
+ peerDependencies:
+ '@internationalized/date': '>=3.0.0'
+
+ '@zag-js/date-utils@1.41.2':
+ resolution: {integrity: sha512-jdcWLa5fYLTsvGWJkx4v/9Hzqf6UHdvJDeP6NxHpwoEmzYy7+AghYKBNSnRrJIBCQJgl1vM9OkpMvYrLNHr9jw==}
+ peerDependencies:
+ '@internationalized/date': '>=3.0.0'
+
+ '@zag-js/dialog@1.41.2':
+ resolution: {integrity: sha512-t1N72snpFGiKj7cg2PivPdsy+X1YdgXPv7bzovpqjMLy0kN+gYTPoV1Iy/hQA+g021dz9XGgXzkDuU45sJqsFA==}
+
+ '@zag-js/dismissable@1.41.2':
+ resolution: {integrity: sha512-hO/tFhRZ7S+LOOljGOQJIubbc3MXg41+iWR1yUXKl76cAenbxaCit1LZmUCwQPvRN0GndK6bDQo5ETjHZz/k7A==}
+
+ '@zag-js/dom-query@1.41.2':
+ resolution: {integrity: sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA==}
+
+ '@zag-js/drawer@1.41.2':
+ resolution: {integrity: sha512-aJql6L0cfHd1wXbemfLcNnjqTA/CbwVgQdWEVn5Qci6zhy4UJXPQeBBfxfgPgEOAbW60gL/gaaXG3d9Vx6+6oA==}
+
+ '@zag-js/editable@1.41.2':
+ resolution: {integrity: sha512-loTM1lrHBBqYfR8SJZrYayVdWHMpXCLVir+aDTQ8/d4bb/Gfl/L8CJNs3BRj3yt9zvLoWTLO+4LOY3hLyQSR2w==}
+
+ '@zag-js/file-upload@1.41.2':
+ resolution: {integrity: sha512-WFwIaKvHpCUjyYMvp42VoydT1WIP5DhDlpmG/nrF4i0ro7pLGb1A4dvyBrAfGcozCB88yR1y1iZKPDY1I9/uUw==}
+
+ '@zag-js/file-utils@1.41.2':
+ resolution: {integrity: sha512-Ih+8ULbId0M+CFR4IsqG5y/0VLCk2l+1rgPH+21L40dlSB6z6qKSP2tG7W69Cj2/3vryZsn67ibn26iCPG/vOw==}
+
+ '@zag-js/floating-panel@1.41.2':
+ resolution: {integrity: sha512-nJP3oZ4YrJh+7H5YdwNccSzPGXFqjQN1ujZ/xGDhegjz4XtL48QMFnAasxlRI8VGjse9Tj8VXlQZxXPrASGzlA==}
+
+ '@zag-js/focus-trap@1.41.2':
+ resolution: {integrity: sha512-3QTtGUjFU2OLbyrDlyoYWvKZecCmtn/+bsfsHW159jJHiEGHVYK6CY8AI1ePsMk9gVay48bXh008j+lVli7gAw==}
+
+ '@zag-js/focus-visible@1.41.2':
+ resolution: {integrity: sha512-oRjwtgafUdGVwLJUN6mKsnBQbez/CHYAPkPg1FxOnr5GFpEpr8oMTOZJ3wdPM2U1ynS9QnUUu/sXc3KQv/jX0Q==}
+
+ '@zag-js/highlight-word@1.41.2':
+ resolution: {integrity: sha512-95zcZKqNrL7JlqAckfzHa+LRbnfoz1lj6skUhq/uHSnni1vH6+8fNWT8ruo9G7vpGopbyRmmcie5p41SbAwQjQ==}
+
+ '@zag-js/hover-card@1.41.2':
+ resolution: {integrity: sha512-Xn9RVzgTkKaVzyJTDdBJiXmCleNi1/hmW8z73tC0vOiQvSSvPejg/JkzqTOLFODvQHK3NOw54QHZvmjYp9Mubw==}
+
+ '@zag-js/i18n-utils@1.41.2':
+ resolution: {integrity: sha512-f1xqaEY79awBxgUyjFso0UEpIoEHZq+zRvB0nUVFHJRW7Ds/QhaFHKSRnf2QBTlP/ObvCT225R+piNAAc17Aaw==}
+
+ '@zag-js/image-cropper@1.41.2':
+ resolution: {integrity: sha512-750aT4U+J/TJw/Z1QVoLU9JG0luCtf9CyvShtJFIxeS+i25aUoBI9pOKgSFABsOutIqNJTPq4gYpqtuxFjSxRw==}
+
+ '@zag-js/interact-outside@1.41.2':
+ resolution: {integrity: sha512-dM4Fn9iyqQeqkCMRYZP+bAgWEPKRVQRqMmcPsN0OXBhhFKC31Em15LTIZXaOtVKAjH+iwx+UvSYFRiWwEjkOEA==}
+
+ '@zag-js/json-tree-utils@1.41.2':
+ resolution: {integrity: sha512-gNaOzsbCwmTd2HM3/u0xQdWX5UDBfl8tCXFavzbamkFH0iYQOXJb7cqUXBVuI4KScIbHPCKwrzZjqA5Sg9qzAQ==}
+
+ '@zag-js/listbox@1.41.2':
+ resolution: {integrity: sha512-iDGrZleP3ui2Q6Jgmr9RYlbd7njdJHs8Qb3IJrSIZBIeYyYmFvVUfAFjk0g/z/amjTx6uYxRASWSPy/RETd4ug==}
+
+ '@zag-js/live-region@1.41.2':
+ resolution: {integrity: sha512-7ubIW5AQt1wx9S/gFN+rU4TyvuFWJrL/DhnDWPNlH5g3luDVHSNeYGeeqf4d4tkcibtpYZa0pg9CbXxRxrfwVA==}
+
+ '@zag-js/marquee@1.41.2':
+ resolution: {integrity: sha512-cT77aMhrtAK3oe2O6+X3TLtQs8wupdPUtZgHMWVxCcQOwagrk7RUBEQwU3Cx7cTswpBdTKSuDYgUggfgCs96wg==}
+
+ '@zag-js/menu@1.41.2':
+ resolution: {integrity: sha512-wwix8hcAUSi0scpWXCiDppfdZV01Za8nN0gqLt9GdhCiVSlr0rs9pK1ROgPKJTyc43UZfyFPqtTWVHvEHMM70w==}
+
+ '@zag-js/navigation-menu@1.41.2':
+ resolution: {integrity: sha512-aROB9CHzskZpnoFuGFkp7dbkZdsXvp2nxQgsgld02I1sDqiwcQd+YMdB0/6Ik0oz6X8I70RKdUuQM9WQFQfDnA==}
+
+ '@zag-js/number-input@1.41.2':
+ resolution: {integrity: sha512-QoQWCiVHO+ciUbq8uL8Kxhtk4o3UpwzYpJkfsOfitzsZoYpPc7V/A6+n5yABV2SOwpqBODwNASZdxiEa60kfow==}
+
+ '@zag-js/pagination@1.41.2':
+ resolution: {integrity: sha512-vZTxz4DrIDfedMcTjDeEkOc1iXD9wkP8eKuEcDieHOodnjSnNNwtmoFw5DCWv+yEa/TByIamXBZ8ZxHY144JgA==}
+
+ '@zag-js/password-input@1.41.2':
+ resolution: {integrity: sha512-OWKFl0S12Qnlf4R4WbMCJ/YU0kGfezm5tP0UiyubMO/Fixv6H0twDFaJSPg2F6POv5uomCcGubc1H7gO+fIhsQ==}
+
+ '@zag-js/pin-input@1.41.2':
+ resolution: {integrity: sha512-Wrn3YDbmWL3qvUIzN4QyLO7PzEhunX7z8DwBpmrK04p7HxR9pniTLVIPk27xk2MzyAPYcl4mwd9/Mc88tBxHsg==}
+
+ '@zag-js/popover@1.41.2':
+ resolution: {integrity: sha512-h/LlVMIERM+NWzYV0ZHURlJuaqkT8XxwyEV96XfVzjknDRFNoPSl5IttVYtoaPoUqC0p/Y4oTSiee4mUZKOLHw==}
+
+ '@zag-js/popper@1.41.2':
+ resolution: {integrity: sha512-Iz4D5YAIiIPn4IHGjhX3QatR/RyGaDt43lBSZv0RYcPQYtFg9sUuek3wizjW9qXgdvItevvNMqRdpl7f3es09g==}
+
+ '@zag-js/presence@1.41.2':
+ resolution: {integrity: sha512-OhOLPAf/DYPmgoEntrlrf3LOrkwA+Y0J3K03NXHXPnkRB7h8jyIbHqzHS0jRTb1pvsO2P/yowRyoYtptY2Zmog==}
+
+ '@zag-js/progress@1.41.2':
+ resolution: {integrity: sha512-SnzrqN+Z568NoO4rrgjrIc/S4EXMEne5CDgWwt2kQ8yq9VysdH8TtHAjyciFRIio7cdgEZNHKw9jSccpMWgRwA==}
+
+ '@zag-js/qr-code@1.41.2':
+ resolution: {integrity: sha512-+JLswCNnzf58aQTaX0SMUA9wRC8Hb/a/ZveJIXTz6853Siemay2HqOqB2WQIeF33HNldUFD/a4+4Q8ugg93ubA==}
+
+ '@zag-js/radio-group@1.41.2':
+ resolution: {integrity: sha512-EZjos61jKHZlNw8ez80vG2T9gUwpooxcVpYOKS2hyhuEXn3wEoefS5v1WFbmpoA/8TUpUQnYxisAeNuQfEQCuQ==}
+
+ '@zag-js/rating-group@1.41.2':
+ resolution: {integrity: sha512-SbJP4HiK5XRy/oC3xRowjZOCThq1n/QA2Z/XAkvKLJArVQCYjrH3MoWwWvMVNfNC5+ZJluborR2AGF7tlVLzxA==}
+
+ '@zag-js/react@1.41.2':
+ resolution: {integrity: sha512-5Bx7mQAron4LFWI8Hhs/uw5kwQ19s2Tn30HhctozLqmCu4nnJSTSh7GRvX+uwRZnztGXBXoOrgBWIepU4RXFGg==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@zag-js/rect-utils@1.41.2':
+ resolution: {integrity: sha512-GWBTamaMLMG1p7Fe6V0dsXeTEmk+tqG3ciovzmjxURCJ3Yq2EkAMRhS0v5DG0oo+PyrPEIzEWukGBQkh/XRgYQ==}
+
+ '@zag-js/remove-scroll@1.41.2':
+ resolution: {integrity: sha512-ieIrOgPKlCikAGEBIboQJoU7oxrL5BEY670tDOu7Eg7rNOdAwGXLEKPX2A/q+lREhOiVYjx0D3//vHTvdte80Q==}
+
+ '@zag-js/scroll-area@1.41.2':
+ resolution: {integrity: sha512-hJFAwfIFuS7XmNsS3nB5rPm9OWXfB4d98sID7fjurcaZtDH5LqFriqfXhceNvs7rz7K4f/u8rufXrb6tcvATIA==}
+
+ '@zag-js/scroll-snap@1.41.2':
+ resolution: {integrity: sha512-+70Al6LSASyEZtFyyffUJlDbE88KgYJDud05z8oZTqyEOLlTqnlSNkXq/P3siO7r3sNykg9H+TmAKn+/dVSKuw==}
+
+ '@zag-js/select@1.41.2':
+ resolution: {integrity: sha512-3wGaKABILexoNBJ1bJiHqLLTctR/VMZaNA4cyKiqZeBEWtAkdMhgyY2xKobrP6KtUTqAeUFNVSTu4yCDrAQnUw==}
+
+ '@zag-js/signature-pad@1.41.2':
+ resolution: {integrity: sha512-iNrOxY4gtqhsZdXYvlhF7s+LiOvwV7/kBpNnq8tJ1oYhgTs8wvKtJHrP86/CR05irEXQTaLfmeAJZuBEys9iRA==}
+
+ '@zag-js/slider@1.41.2':
+ resolution: {integrity: sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A==}
+
+ '@zag-js/splitter@1.41.2':
+ resolution: {integrity: sha512-Ubp4hkmzvVysU31jCINYbBXVqruu7rEPGqugWMzeXC5Hwda648aHpbg4Jix/wRtaGLjsyh6KOVEwAmoJU9NwhQ==}
+
+ '@zag-js/steps@1.41.2':
+ resolution: {integrity: sha512-m0t2r8+FWwa2b2aU5JiNrHVdYHyNZYHK0G3Tq9lCOSQoDeoJIkyta+sIVehLVSY+0Ba9kOlkRUmYLbsnfXaW+Q==}
+
+ '@zag-js/store@1.41.2':
+ resolution: {integrity: sha512-dVZF7E1ezXzynrKhMH3rfSr2rBbCfvTjvXbXz7//1PNULuq58UU5dG93V+9l834npCZxI2+PrpY45wZLJPTsIA==}
+
+ '@zag-js/switch@1.41.2':
+ resolution: {integrity: sha512-qHbQK95UUHN0tj+bf9LLphLcMo/uTg2Pvs0c1Gs03Zh4g3NtHf0uYIhMZKYlCH0hNVlKrmdzLKWgeoDxv9gySw==}
+
+ '@zag-js/tabs@1.41.2':
+ resolution: {integrity: sha512-7YVj2mCcxRbn1wMXP9anaTOVf+J0fa5uaPScr8e4+e2xc+/1WKzqN6V8IDeKS5wV/xzi1r3Ny307sX7Xz4ZJVQ==}
+
+ '@zag-js/tags-input@1.41.2':
+ resolution: {integrity: sha512-aIPEndSO+9LHxyoXLUr0Uttxw2cYMyDuii5w50wn/N7lFJD49U3Sj+6XaB/oJSbDAwn10WrsRDtb7Gs2kmU+Qw==}
+
+ '@zag-js/timer@1.41.2':
+ resolution: {integrity: sha512-PRYLWaip0+1FeVGEMNk5wMGAAIYgBIWwulZ4U5I+2Ayjohzp2NUAfwJ3sqoYvRrfjNYUNAPdU4eGu1zetC+oVQ==}
+
+ '@zag-js/toast@1.41.2':
+ resolution: {integrity: sha512-+F3PsAo6EIz4rh73IOMCb/+FOUp7e3VjUY2q5sdU4IbfOzJBIbVSJxn0PQmHkuxkzWdCom0Lv0qSPFg/UTplnQ==}
+
+ '@zag-js/toggle-group@1.41.2':
+ resolution: {integrity: sha512-C6wn3A89h24hTs0BN9ryEuKatfR493u7QqxS06TeK9oI/KZBvm5Rwm8FPHSUJvsUTkAkow4PsXC0Ra19duzEdQ==}
+
+ '@zag-js/toggle@1.41.2':
+ resolution: {integrity: sha512-EFB9pb3pEtwXt7RSivVLWXV64dKUF8gsn75tt8TbYBgfy+zW85MsRLu8U48TXZN5teQWAKKNujr9bxQsW/CWvQ==}
+
+ '@zag-js/tooltip@1.41.2':
+ resolution: {integrity: sha512-68okWJCFXfW8r0h97kEcU2yKPkq6e0S6QkiYh09ifMXoYjrQw/shPol8PgrS1poqKJigUWtsKm+bw73abhMn3w==}
+
+ '@zag-js/tour@1.41.2':
+ resolution: {integrity: sha512-Q7UsvuHYYBo1Cs4b4OS0e/D8lxd2GpSRII93s5BQPi5HTcBjaWhVysAykmCFbat6Z56z0NBmFHNdhZ8oVPls1A==}
+
+ '@zag-js/tree-view@1.41.2':
+ resolution: {integrity: sha512-QNi0VpV+RyzF4NP72+kSleUpauF9SMAzOAe59nxvs8jAUHqV5diDCInnbQos4+cyFDXFzGq3lElot26A1yI+7Q==}
+
+ '@zag-js/types@1.41.2':
+ resolution: {integrity: sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ==}
+
+ '@zag-js/utils@1.41.2':
+ resolution: {integrity: sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==}
+
+ acorn@8.17.0:
+ resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv-draft-04@1.0.0:
+ resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
+ peerDependencies:
+ ajv: ^8.5.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@8.18.0:
+ resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
+
+ alien-signals@0.4.14:
+ resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==}
+
+ argparse@1.0.10:
+ resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
+
+ babel-plugin-macros@3.1.0:
+ resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
+ engines: {node: '>=10', npm: '>=6'}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ brace-expansion@5.0.7:
+ resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
+ engines: {node: 18 || 20 || >=22}
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ compare-versions@6.1.1:
+ resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
+
+ confbox@0.1.8:
+ resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+
+ confbox@0.2.4:
+ resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
+
+ convert-source-map@1.9.0:
+ resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+
+ cosmiconfig@7.1.0:
+ resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
+ engines: {node: '>=10'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ de-indent@1.0.2:
+ resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ error-ex@1.3.4:
+ resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ exsolve@1.1.0:
+ resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-uri@4.1.0:
+ resolution: {integrity: sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: '>=4.0.4'
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ find-root@1.1.0:
+ resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
+
+ fs-extra@11.3.6:
+ resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
+ engines: {node: '>=14.14'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
+ hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ import-lazy@4.0.0:
+ resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==}
+ engines: {node: '>=8'}
+
+ is-arrayish@0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+
+ is-core-module@2.16.2:
+ resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
+ engines: {node: '>= 0.4'}
+
+ jju@1.4.0:
+ resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-parse-even-better-errors@2.3.1:
+ resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
+ jsonfile@6.2.1:
+ resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
+
+ kolorist@1.8.0:
+ resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ local-pkg@1.2.1:
+ resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
+ engines: {node: '>=14'}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ minimatch@10.2.3:
+ resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ mlly@1.8.2:
+ resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ nanoid@3.3.15:
+ resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parse-json@5.2.0:
+ resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
+ engines: {node: '>=8'}
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ path-parse@1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+
+ path-type@4.0.0:
+ resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+ engines: {node: '>=8'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ perfect-freehand@1.2.3:
+ resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ pkg-types@1.3.1:
+ resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+
+ pkg-types@2.3.1:
+ resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+
+ postcss@8.5.16:
+ resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ proxy-compare@3.0.1:
+ resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
+
+ proxy-memoize@3.0.1:
+ resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==}
+
+ quansync@0.2.11:
+ resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
+
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ resolve@1.22.12:
+ resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
+ engines: {node: '>= 0.4'}
+ hasBin: true
+
+ rolldown@1.1.4:
+ resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.5.7:
+ resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ sprintf-js@1.0.3:
+ resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+
+ string-argv@0.3.2:
+ resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
+ engines: {node: '>=0.6.19'}
+
+ stylis@4.2.0:
+ resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
+
+ supports-color@8.1.1:
+ resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+ engines: {node: '>=10'}
+
+ supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ ufo@1.6.4:
+ resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
+
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
+ uqr@0.1.3:
+ resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==}
+
+ vite-plugin-css-injected-by-js@3.5.2:
+ resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==}
+ peerDependencies:
+ vite: '>=7.3.2'
+
+ vite-plugin-dts@4.5.4:
+ resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==}
+ peerDependencies:
+ typescript: '*'
+ vite: '>=7.3.2'
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
+ vite@8.1.3:
+ resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.3.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vscode-uri@3.1.0:
+ resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
+
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
+snapshots:
+
+ '@ark-ui/react@5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@internationalized/date': 3.12.2
+ '@zag-js/accordion': 1.41.2
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/angle-slider': 1.41.2
+ '@zag-js/async-list': 1.41.2
+ '@zag-js/auto-resize': 1.41.2
+ '@zag-js/avatar': 1.41.2
+ '@zag-js/carousel': 1.41.2
+ '@zag-js/cascade-select': 1.41.2
+ '@zag-js/checkbox': 1.41.2
+ '@zag-js/clipboard': 1.41.2
+ '@zag-js/collapsible': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/color-picker': 1.41.2
+ '@zag-js/color-utils': 1.41.2
+ '@zag-js/combobox': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/date-input': 1.41.2(@internationalized/date@3.12.2)
+ '@zag-js/date-picker': 1.41.2(@internationalized/date@3.12.2)
+ '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2)
+ '@zag-js/dialog': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/drawer': 1.41.2
+ '@zag-js/editable': 1.41.2
+ '@zag-js/file-upload': 1.41.2
+ '@zag-js/file-utils': 1.41.2
+ '@zag-js/floating-panel': 1.41.2
+ '@zag-js/focus-trap': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/highlight-word': 1.41.2
+ '@zag-js/hover-card': 1.41.2
+ '@zag-js/i18n-utils': 1.41.2
+ '@zag-js/image-cropper': 1.41.2
+ '@zag-js/json-tree-utils': 1.41.2
+ '@zag-js/listbox': 1.41.2
+ '@zag-js/marquee': 1.41.2
+ '@zag-js/menu': 1.41.2
+ '@zag-js/navigation-menu': 1.41.2
+ '@zag-js/number-input': 1.41.2
+ '@zag-js/pagination': 1.41.2
+ '@zag-js/password-input': 1.41.2
+ '@zag-js/pin-input': 1.41.2
+ '@zag-js/popover': 1.41.2
+ '@zag-js/presence': 1.41.2
+ '@zag-js/progress': 1.41.2
+ '@zag-js/qr-code': 1.41.2
+ '@zag-js/radio-group': 1.41.2
+ '@zag-js/rating-group': 1.41.2
+ '@zag-js/react': 1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@zag-js/scroll-area': 1.41.2
+ '@zag-js/select': 1.41.2
+ '@zag-js/signature-pad': 1.41.2
+ '@zag-js/slider': 1.41.2
+ '@zag-js/splitter': 1.41.2
+ '@zag-js/steps': 1.41.2
+ '@zag-js/switch': 1.41.2
+ '@zag-js/tabs': 1.41.2
+ '@zag-js/tags-input': 1.41.2
+ '@zag-js/timer': 1.41.2
+ '@zag-js/toast': 1.41.2
+ '@zag-js/toggle': 1.41.2
+ '@zag-js/toggle-group': 1.41.2
+ '@zag-js/tooltip': 1.41.2
+ '@zag-js/tour': 1.41.2
+ '@zag-js/tree-view': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@chakra-ui/react@3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@ark-ui/react': 5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@emotion/is-prop-valid': 1.4.0
+ '@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7)
+ '@emotion/serialize': 1.3.3
+ '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7)
+ '@emotion/utils': 1.4.2
+ '@pandacss/is-valid-prop': 1.11.4
+ csstype: 3.2.3
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ '@emnapi/core@1.11.1':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.2
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.11.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emotion/babel-plugin@11.13.5':
+ dependencies:
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@emotion/hash': 0.9.2
+ '@emotion/memoize': 0.9.0
+ '@emotion/serialize': 1.3.3
+ babel-plugin-macros: 3.1.0
+ convert-source-map: 1.9.0
+ escape-string-regexp: 4.0.0
+ find-root: 1.1.0
+ source-map: 0.5.7
+ stylis: 4.2.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/cache@11.14.0':
+ dependencies:
+ '@emotion/memoize': 0.9.0
+ '@emotion/sheet': 1.4.0
+ '@emotion/utils': 1.4.2
+ '@emotion/weak-memoize': 0.4.0
+ stylis: 4.2.0
+
+ '@emotion/hash@0.9.2': {}
+
+ '@emotion/is-prop-valid@1.4.0':
+ dependencies:
+ '@emotion/memoize': 0.9.0
+
+ '@emotion/memoize@0.9.0': {}
+
+ '@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/babel-plugin': 11.13.5
+ '@emotion/cache': 11.14.0
+ '@emotion/serialize': 1.3.3
+ '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7)
+ '@emotion/utils': 1.4.2
+ '@emotion/weak-memoize': 0.4.0
+ hoist-non-react-statics: 3.3.2
+ react: 19.2.7
+ optionalDependencies:
+ '@types/react': 19.2.17
+ transitivePeerDependencies:
+ - supports-color
+
+ '@emotion/serialize@1.3.3':
+ dependencies:
+ '@emotion/hash': 0.9.2
+ '@emotion/memoize': 0.9.0
+ '@emotion/unitless': 0.10.0
+ '@emotion/utils': 1.4.2
+ csstype: 3.2.3
+
+ '@emotion/sheet@1.4.0': {}
+
+ '@emotion/unitless@0.10.0': {}
+
+ '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.7)':
+ dependencies:
+ react: 19.2.7
+
+ '@emotion/utils@1.4.2': {}
+
+ '@emotion/weak-memoize@0.4.0': {}
+
+ '@floating-ui/core@1.7.5':
+ dependencies:
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/dom@1.7.6':
+ dependencies:
+ '@floating-ui/core': 1.7.5
+ '@floating-ui/utils': 0.2.11
+
+ '@floating-ui/utils@0.2.11': {}
+
+ '@internationalized/date@3.12.2':
+ dependencies:
+ '@swc/helpers': 0.5.23
+
+ '@internationalized/number@3.6.6':
+ dependencies:
+ '@swc/helpers': 0.5.23
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@microsoft/api-extractor-model@7.33.8':
+ dependencies:
+ '@microsoft/tsdoc': 0.16.0
+ '@microsoft/tsdoc-config': 0.18.1
+ '@rushstack/node-core-library': 5.23.1
+ transitivePeerDependencies:
+ - '@types/node'
+
+ '@microsoft/api-extractor@7.58.9':
+ dependencies:
+ '@microsoft/api-extractor-model': 7.33.8
+ '@microsoft/tsdoc': 0.16.0
+ '@microsoft/tsdoc-config': 0.18.1
+ '@rushstack/node-core-library': 5.23.1
+ '@rushstack/rig-package': 0.7.3
+ '@rushstack/terminal': 0.24.0
+ '@rushstack/ts-command-line': 5.3.10
+ diff: 8.0.4
+ minimatch: 10.2.3
+ resolve: 1.22.12
+ semver: 7.7.4
+ source-map: 0.6.1
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/node'
+
+ '@microsoft/tsdoc-config@0.18.1':
+ dependencies:
+ '@microsoft/tsdoc': 0.16.0
+ ajv: 8.18.0
+ jju: 1.4.0
+ resolve: 1.22.12
+
+ '@microsoft/tsdoc@0.16.0': {}
+
+ '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@tybys/wasm-util': 0.10.3
+ optional: true
+
+ '@oxc-project/types@0.138.0': {}
+
+ '@pandacss/is-valid-prop@1.11.4': {}
+
+ '@rolldown/binding-android-arm64@1.1.4':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.1.4':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.1.4':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.1.4':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.1.4':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.1.4':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.1.4':
+ dependencies:
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
+ '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.1.4':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.1.4':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@rollup/pluginutils@5.4.0':
+ dependencies:
+ '@types/estree': 1.0.9
+ estree-walker: 2.0.2
+ picomatch: 4.0.4
+
+ '@rushstack/node-core-library@5.23.1':
+ dependencies:
+ ajv: 8.18.0
+ ajv-draft-04: 1.0.0(ajv@8.18.0)
+ ajv-formats: 3.0.1(ajv@8.18.0)
+ fs-extra: 11.3.6
+ import-lazy: 4.0.0
+ jju: 1.4.0
+ resolve: 1.22.12
+ semver: 7.7.4
+
+ '@rushstack/problem-matcher@0.2.1': {}
+
+ '@rushstack/rig-package@0.7.3':
+ dependencies:
+ jju: 1.4.0
+ resolve: 1.22.12
+
+ '@rushstack/terminal@0.24.0':
+ dependencies:
+ '@rushstack/node-core-library': 5.23.1
+ '@rushstack/problem-matcher': 0.2.1
+ supports-color: 8.1.1
+
+ '@rushstack/ts-command-line@5.3.10':
+ dependencies:
+ '@rushstack/terminal': 0.24.0
+ '@types/argparse': 1.0.38
+ argparse: 1.0.10
+ string-argv: 0.3.2
+ transitivePeerDependencies:
+ - '@types/node'
+
+ '@swc/core-darwin-arm64@1.15.43':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.43':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.43':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.43':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.43':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.43':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.43':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.43':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.43':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.43':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.43':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.43':
+ optional: true
+
+ '@swc/core@1.15.43(@swc/helpers@0.5.23)':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.27
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.43
+ '@swc/core-darwin-x64': 1.15.43
+ '@swc/core-linux-arm-gnueabihf': 1.15.43
+ '@swc/core-linux-arm64-gnu': 1.15.43
+ '@swc/core-linux-arm64-musl': 1.15.43
+ '@swc/core-linux-ppc64-gnu': 1.15.43
+ '@swc/core-linux-s390x-gnu': 1.15.43
+ '@swc/core-linux-x64-gnu': 1.15.43
+ '@swc/core-linux-x64-musl': 1.15.43
+ '@swc/core-win32-arm64-msvc': 1.15.43
+ '@swc/core-win32-ia32-msvc': 1.15.43
+ '@swc/core-win32-x64-msvc': 1.15.43
+ '@swc/helpers': 0.5.23
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/helpers@0.5.23':
+ dependencies:
+ tslib: 2.8.1
+
+ '@swc/types@0.1.27':
+ dependencies:
+ '@swc/counter': 0.1.3
+
+ '@tybys/wasm-util@0.10.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/argparse@1.0.38': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/parse-json@4.0.2': {}
+
+ '@types/react-dom@19.2.3(@types/react@19.2.17)':
+ dependencies:
+ '@types/react': 19.2.17
+
+ '@types/react@19.2.17':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react-swc@4.3.1(@swc/helpers@0.5.23)(vite@8.1.3(yaml@2.9.0))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ '@swc/core': 1.15.43(@swc/helpers@0.5.23)
+ vite: 8.1.3(yaml@2.9.0)
+ transitivePeerDependencies:
+ - '@swc/helpers'
+
+ '@volar/language-core@2.4.28':
+ dependencies:
+ '@volar/source-map': 2.4.28
+
+ '@volar/source-map@2.4.28': {}
+
+ '@volar/typescript@2.4.28':
+ dependencies:
+ '@volar/language-core': 2.4.28
+ path-browserify: 1.0.1
+ vscode-uri: 3.1.0
+
+ '@vue/compiler-core@3.5.39':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@vue/shared': 3.5.39
+ entities: 7.0.1
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.39':
+ dependencies:
+ '@vue/compiler-core': 3.5.39
+ '@vue/shared': 3.5.39
+
+ '@vue/compiler-vue2@2.7.16':
+ dependencies:
+ de-indent: 1.0.2
+ he: 1.2.0
+
+ '@vue/language-core@2.2.0(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.28
+ '@vue/compiler-dom': 3.5.39
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.39
+ alien-signals: 0.4.14
+ minimatch: 9.0.9
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/shared@3.5.39': {}
+
+ '@zag-js/accordion@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/anatomy@1.41.2': {}
+
+ '@zag-js/angle-slider@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/rect-utils': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/aria-hidden@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/async-list@1.41.2':
+ dependencies:
+ '@zag-js/core': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/auto-resize@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/avatar@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/carousel@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/scroll-snap': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/cascade-select@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/rect-utils': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/checkbox@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/clipboard@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/collapsible@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/collection@1.41.2':
+ dependencies:
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/color-picker@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/color-utils': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/color-utils@1.41.2':
+ dependencies:
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/combobox@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/live-region': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/core@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/date-input@1.41.2(@internationalized/date@3.12.2)':
+ dependencies:
+ '@internationalized/date': 3.12.2
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2)
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/live-region': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/date-picker@1.41.2(@internationalized/date@3.12.2)':
+ dependencies:
+ '@internationalized/date': 3.12.2
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2)
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/live-region': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/date-utils@1.41.2(@internationalized/date@3.12.2)':
+ dependencies:
+ '@internationalized/date': 3.12.2
+
+ '@zag-js/dialog@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/aria-hidden': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-trap': 1.41.2
+ '@zag-js/remove-scroll': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/dismissable@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/interact-outside': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/dom-query@1.41.2':
+ dependencies:
+ '@zag-js/types': 1.41.2
+
+ '@zag-js/drawer@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/aria-hidden': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-trap': 1.41.2
+ '@zag-js/remove-scroll': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/editable@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/interact-outside': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/file-upload@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/file-utils': 1.41.2
+ '@zag-js/i18n-utils': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/file-utils@1.41.2':
+ dependencies:
+ '@zag-js/i18n-utils': 1.41.2
+
+ '@zag-js/floating-panel@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/rect-utils': 1.41.2
+ '@zag-js/store': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/focus-trap@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/focus-visible@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/highlight-word@1.41.2': {}
+
+ '@zag-js/hover-card@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/i18n-utils@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/image-cropper@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/interact-outside@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/json-tree-utils@1.41.2': {}
+
+ '@zag-js/listbox@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/live-region@1.41.2': {}
+
+ '@zag-js/marquee@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/menu@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/rect-utils': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/navigation-menu@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/number-input@1.41.2':
+ dependencies:
+ '@internationalized/number': 3.6.6
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/pagination@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/password-input@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/pin-input@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/popover@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/aria-hidden': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-trap': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/remove-scroll': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/popper@1.41.2':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/presence@1.41.2':
+ dependencies:
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+
+ '@zag-js/progress@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/qr-code@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+ proxy-memoize: 3.0.1
+ uqr: 0.1.3
+
+ '@zag-js/radio-group@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/rating-group@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/react@1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@zag-js/core': 1.41.2
+ '@zag-js/store': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ '@zag-js/rect-utils@1.41.2': {}
+
+ '@zag-js/remove-scroll@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/scroll-area@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/scroll-snap@1.41.2':
+ dependencies:
+ '@zag-js/dom-query': 1.41.2
+
+ '@zag-js/select@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/signature-pad@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+ perfect-freehand: 1.2.3
+
+ '@zag-js/slider@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/splitter@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/steps@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/store@1.41.2':
+ dependencies:
+ proxy-compare: 3.0.1
+
+ '@zag-js/switch@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/tabs@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/tags-input@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/auto-resize': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/interact-outside': 1.41.2
+ '@zag-js/live-region': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/timer@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/toast@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/toggle-group@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/toggle@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/tooltip@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-visible': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/tour@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dismissable': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/focus-trap': 1.41.2
+ '@zag-js/interact-outside': 1.41.2
+ '@zag-js/popper': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/tree-view@1.41.2':
+ dependencies:
+ '@zag-js/anatomy': 1.41.2
+ '@zag-js/collection': 1.41.2
+ '@zag-js/core': 1.41.2
+ '@zag-js/dom-query': 1.41.2
+ '@zag-js/types': 1.41.2
+ '@zag-js/utils': 1.41.2
+
+ '@zag-js/types@1.41.2':
+ dependencies:
+ csstype: 3.2.3
+
+ '@zag-js/utils@1.41.2': {}
+
+ acorn@8.17.0: {}
+
+ ajv-draft-04@1.0.0(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
+ ajv-formats@3.0.1(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
+ ajv@8.18.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 4.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ alien-signals@0.4.14: {}
+
+ argparse@1.0.10:
+ dependencies:
+ sprintf-js: 1.0.3
+
+ babel-plugin-macros@3.1.0:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ cosmiconfig: 7.1.0
+ resolve: 1.22.12
+
+ balanced-match@4.0.4: {}
+
+ brace-expansion@5.0.7:
+ dependencies:
+ balanced-match: 4.0.4
+
+ callsites@3.1.0: {}
+
+ compare-versions@6.1.1: {}
+
+ confbox@0.1.8: {}
+
+ confbox@0.2.4: {}
+
+ convert-source-map@1.9.0: {}
+
+ cosmiconfig@7.1.0:
+ dependencies:
+ '@types/parse-json': 4.0.2
+ import-fresh: 3.3.1
+ parse-json: 5.2.0
+ path-type: 4.0.0
+ yaml: 2.9.0
+
+ csstype@3.2.3: {}
+
+ de-indent@1.0.2: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ detect-libc@2.1.2: {}
+
+ diff@8.0.4: {}
+
+ entities@7.0.1: {}
+
+ error-ex@1.3.4:
+ dependencies:
+ is-arrayish: 0.2.1
+
+ es-errors@1.3.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ estree-walker@2.0.2: {}
+
+ exsolve@1.1.0: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-uri@4.1.0: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ find-root@1.1.0: {}
+
+ fs-extra@11.3.6:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ graceful-fs@4.2.11: {}
+
+ has-flag@4.0.0: {}
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ he@1.2.0: {}
+
+ hoist-non-react-statics@3.3.2:
+ dependencies:
+ react-is: 16.13.1
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ import-lazy@4.0.0: {}
+
+ is-arrayish@0.2.1: {}
+
+ is-core-module@2.16.2:
+ dependencies:
+ hasown: 2.0.4
+
+ jju@1.4.0: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json-parse-even-better-errors@2.3.1: {}
+
+ json-schema-traverse@1.0.0: {}
+
+ jsonfile@6.2.1:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
+ kolorist@1.8.0: {}
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ lines-and-columns@1.2.4: {}
+
+ local-pkg@1.2.1:
+ dependencies:
+ mlly: 1.8.2
+ pkg-types: 2.3.1
+ quansync: 0.2.11
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ minimatch@10.2.3:
+ dependencies:
+ brace-expansion: 5.0.7
+
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 5.0.7
+
+ mlly@1.8.2:
+ dependencies:
+ acorn: 8.17.0
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ ufo: 1.6.4
+
+ ms@2.1.3: {}
+
+ muggle-string@0.4.1: {}
+
+ nanoid@3.3.15: {}
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse-json@5.2.0:
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ error-ex: 1.3.4
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 1.2.4
+
+ path-browserify@1.0.1: {}
+
+ path-parse@1.0.7: {}
+
+ path-type@4.0.0: {}
+
+ pathe@2.0.3: {}
+
+ perfect-freehand@1.2.3: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ pkg-types@1.3.1:
+ dependencies:
+ confbox: 0.1.8
+ mlly: 1.8.2
+ pathe: 2.0.3
+
+ pkg-types@2.3.1:
+ dependencies:
+ confbox: 0.2.4
+ exsolve: 1.1.0
+ pathe: 2.0.3
+
+ postcss@8.5.16:
+ dependencies:
+ nanoid: 3.3.15
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ proxy-compare@3.0.1: {}
+
+ proxy-memoize@3.0.1:
+ dependencies:
+ proxy-compare: 3.0.1
+
+ quansync@0.2.11: {}
+
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react-is@16.13.1: {}
+
+ react@19.2.7: {}
+
+ require-from-string@2.0.2: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve@1.22.12:
+ dependencies:
+ es-errors: 1.3.0
+ is-core-module: 2.16.2
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+
+ rolldown@1.1.4:
+ dependencies:
+ '@oxc-project/types': 0.138.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.1.4
+ '@rolldown/binding-darwin-arm64': 1.1.4
+ '@rolldown/binding-darwin-x64': 1.1.4
+ '@rolldown/binding-freebsd-x64': 1.1.4
+ '@rolldown/binding-linux-arm-gnueabihf': 1.1.4
+ '@rolldown/binding-linux-arm64-gnu': 1.1.4
+ '@rolldown/binding-linux-arm64-musl': 1.1.4
+ '@rolldown/binding-linux-ppc64-gnu': 1.1.4
+ '@rolldown/binding-linux-s390x-gnu': 1.1.4
+ '@rolldown/binding-linux-x64-gnu': 1.1.4
+ '@rolldown/binding-linux-x64-musl': 1.1.4
+ '@rolldown/binding-openharmony-arm64': 1.1.4
+ '@rolldown/binding-wasm32-wasi': 1.1.4
+ '@rolldown/binding-win32-arm64-msvc': 1.1.4
+ '@rolldown/binding-win32-x64-msvc': 1.1.4
+
+ scheduler@0.27.0: {}
+
+ semver@7.7.4: {}
+
+ source-map-js@1.2.1: {}
+
+ source-map@0.5.7: {}
+
+ source-map@0.6.1: {}
+
+ sprintf-js@1.0.3: {}
+
+ string-argv@0.3.2: {}
+
+ stylis@4.2.0: {}
+
+ supports-color@8.1.1:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-preserve-symlinks-flag@1.0.0: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tslib@2.8.1: {}
+
+ typescript@5.9.3: {}
+
+ ufo@1.6.4: {}
+
+ universalify@2.0.1: {}
+
+ uqr@0.1.3: {}
+
+ vite-plugin-css-injected-by-js@3.5.2(vite@8.1.3(yaml@2.9.0)):
+ dependencies:
+ vite: 8.1.3(yaml@2.9.0)
+
+ vite-plugin-dts@4.5.4(typescript@5.9.3)(vite@8.1.3(yaml@2.9.0)):
+ dependencies:
+ '@microsoft/api-extractor': 7.58.9
+ '@rollup/pluginutils': 5.4.0
+ '@volar/typescript': 2.4.28
+ '@vue/language-core': 2.2.0(typescript@5.9.3)
+ compare-versions: 6.1.1
+ debug: 4.4.3
+ kolorist: 1.8.0
+ local-pkg: 1.2.1
+ magic-string: 0.30.21
+ typescript: 5.9.3
+ optionalDependencies:
+ vite: 8.1.3(yaml@2.9.0)
+ transitivePeerDependencies:
+ - '@types/node'
+ - rollup
+ - supports-color
+
+ vite@8.1.3(yaml@2.9.0):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.16
+ rolldown: 1.1.4
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+ yaml: 2.9.0
+
+ vscode-uri@3.1.0: {}
+
+ yaml@2.9.0: {}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/api.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/api.ts
new file mode 100644
index 0000000000000..7b0b670356d7c
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/api.ts
@@ -0,0 +1,141 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { AgentItem, ObservationIO, TraceListItem, TraceSummary } from "src/types";
+
+function getBase(): string {
+ if (typeof document === "undefined") return "/ai-trace";
+ const baseHref = document.querySelector("head > base")?.getAttribute("href") ?? "";
+ const baseUrl = new URL(baseHref, globalThis.location.origin);
+ const basePath = baseUrl.pathname.replace(/\/$/, "") || "";
+ return basePath ? `${basePath}/ai-trace` : "/ai-trace";
+}
+
+const BASE = getBase();
+
+export class ApiError extends Error {}
+
+export async function fetchTraceSummary(
+ dagId: string,
+ runId: string,
+ taskId: string,
+ mapIndex: number,
+): Promise {
+ const qs =
+ `dag_id=${encodeURIComponent(dagId)}` +
+ `&run_id=${encodeURIComponent(runId)}` +
+ `&task_id=${encodeURIComponent(taskId)}` +
+ `&map_index=${mapIndex}`;
+ const res = await fetch(`${BASE}/trace-summary?${qs}`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const detail = (body as { detail?: string }).detail;
+ throw new ApiError(detail ?? res.statusText);
+ }
+ return res.json() as Promise;
+}
+
+export interface ListTracesParams {
+ dagId?: string;
+ minCost?: number;
+ minLatency?: number;
+ since?: string;
+ state?: string;
+ taskId?: string;
+}
+
+export async function fetchTraceList(params: ListTracesParams = {}): Promise {
+ const usp = new URLSearchParams();
+ if (params.dagId) usp.set("dag_id", params.dagId);
+ if (params.taskId) usp.set("task_id", params.taskId);
+ if (params.since) usp.set("since", params.since);
+ if (params.state) usp.set("state", params.state);
+ if (params.minLatency != null) usp.set("min_latency", String(params.minLatency));
+ if (params.minCost != null) usp.set("min_cost", String(params.minCost));
+ const qs = usp.toString();
+ const res = await fetch(`${BASE}/traces${qs ? `?${qs}` : ""}`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const detail = (body as { detail?: string }).detail;
+ throw new ApiError(detail ?? res.statusText);
+ }
+ const data = (await res.json()) as { items: TraceListItem[] };
+ return data.items;
+}
+
+export async function fetchAgents(): Promise {
+ const res = await fetch(`${BASE}/agents`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const detail = (body as { detail?: string }).detail;
+ throw new ApiError(detail ?? res.statusText);
+ }
+ const data = (await res.json()) as { items: AgentItem[] };
+ return data.items;
+}
+
+// Short-TTL cache: a finished task's trace is immutable, but a RUNNING agent's
+// trace is still growing, so unlike observation IO this must expire.
+const TRACE_TTL_MS = 60_000;
+const traceCache = new Map }>();
+
+export function fetchTraceById(traceId: string): Promise {
+ const cached = traceCache.get(traceId);
+ if (cached && Date.now() - cached.at < TRACE_TTL_MS) return cached.promise;
+ const promise = (async () => {
+ const res = await fetch(`${BASE}/trace/${encodeURIComponent(traceId)}`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const detail = (body as { detail?: string }).detail;
+ throw new ApiError(detail ?? res.statusText);
+ }
+ return (await res.json()) as TraceSummary;
+ })().catch((err: unknown) => {
+ traceCache.delete(traceId);
+ throw err;
+ });
+ traceCache.set(traceId, { at: Date.now(), promise });
+ return promise;
+}
+
+
+// Observations are immutable once written, so cache their IO for the page's
+// lifetime -- closing/reopening the modal must not re-hit the backend (which
+// proxies to Langfuse). Poor man's react-query; failures are evicted so a
+// transient error doesn't stick.
+const obsIOCache = new Map>();
+
+export function fetchObservationIO(obsId: string): Promise {
+ const cached = obsIOCache.get(obsId);
+ if (cached) return cached;
+ const promise = (async () => {
+ const res = await fetch(`${BASE}/observations/${encodeURIComponent(obsId)}`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const detail = (body as { detail?: string }).detail;
+ throw new ApiError(detail ?? res.statusText);
+ }
+ return (await res.json()) as ObservationIO;
+ })().catch((err: unknown) => {
+ obsIOCache.delete(obsId);
+ throw err;
+ });
+ obsIOCache.set(obsId, promise);
+ return promise;
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/AgentsView.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/AgentsView.tsx
new file mode 100644
index 0000000000000..08db1e139704f
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/AgentsView.tsx
@@ -0,0 +1,117 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Badge, Box, Code, Spinner, Table, Text } from "@chakra-ui/react";
+import { type FC, useEffect, useState } from "react";
+
+import { ApiError, fetchAgents } from "src/api";
+import type { AgentItem } from "src/types";
+import { fmtTimestamp } from "src/util";
+
+// Deployment-wide agent inventory: one row per (dag, task) that ran a
+// common.ai operator. Row click drills into that agent's traces.
+export const AgentsView: FC<{ onSelect: (dagId: string, taskId: string) => void }> = ({ onSelect }) => {
+ const [items, setItems] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchAgents()
+ .then(setItems)
+ .catch((err: unknown) => {
+ setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err));
+ });
+ }, []);
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (items === null) {
+ return (
+
+
+
+ );
+ }
+
+ if (items.length === 0) {
+ return (
+
+ No agents found. Tasks using @task.agent /{" "}
+ @task.llm (or the common.ai operators) will show up here.
+
+ );
+ }
+
+ return (
+
+
+
+ Agent
+ Operator
+ Runs
+ Failed
+ Last run
+
+
+
+ {items.map((a) => (
+ onSelect(a.dag_id, a.task_id)}
+ >
+
+
+ {a.dag_id}
+
+ .{a.task_id}
+
+
+
+
+
+ {a.operator ?? "?"}
+
+
+
+ {a.runs}
+
+
+ {a.failed > 0 ? (
+ {a.failed}
+ ) : (
+
+ 0
+
+ )}
+
+
+ {fmtTimestamp(a.last_run)}
+
+
+ ))}
+
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Aggregates.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Aggregates.tsx
new file mode 100644
index 0000000000000..e2f04ff6a07cd
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Aggregates.tsx
@@ -0,0 +1,45 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HStack, Text } from "@chakra-ui/react";
+import type { FC } from "react";
+
+import type { TraceListItem } from "src/types";
+import { aggregate, fmtCost, fmtLatency } from "src/util";
+
+const Stat: FC<{ label: string; value: string }> = ({ label, value }) => (
+
+
+ {value}
+ {" "}
+ {label}
+
+);
+
+export const Aggregates: FC<{ items: TraceListItem[] }> = ({ items }) => {
+ const a = aggregate(items);
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Filters.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Filters.tsx
new file mode 100644
index 0000000000000..d482a2cf74d37
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/Filters.tsx
@@ -0,0 +1,106 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Button, HStack, Input } from "@chakra-ui/react";
+import type { FC } from "react";
+
+import type { TimeWindow } from "src/util";
+
+const WINDOWS: { id: TimeWindow; label: string }[] = [
+ { id: "1h", label: "1h" },
+ { id: "24h", label: "24h" },
+ { id: "7d", label: "7d" },
+ { id: "all", label: "All" },
+];
+
+// State chip labels stay lowercase deliberately: they're the literal
+// TaskInstance state values, matching how Airflow renders state badges.
+const STATES: { id: string; label: string }[] = [
+ { id: "", label: "Any state" },
+ { id: "success", label: "success" },
+ { id: "failed", label: "failed" },
+ { id: "running", label: "running" },
+];
+
+export interface FilterState {
+ minCost: string;
+ minLatency: string;
+ q: string;
+ state: string;
+ window: TimeWindow;
+}
+
+// Default to 7d, not 24h: an agent DAG that last ran two days ago would greet
+// every fresh visit with "0 traces" (the first-run cliff both UX reviews hit).
+export const DEFAULT_FILTERS: FilterState = { minCost: "", minLatency: "", q: "", state: "", window: "7d" };
+
+const Chips: FC<{
+ onChange: (id: string) => void;
+ options: { id: string; label: string }[];
+ value: string;
+}> = ({ onChange, options, value }) => (
+
+ {options.map((o) => (
+
+ ))}
+
+);
+
+export const Filters: FC<{ onChange: (next: FilterState) => void; value: FilterState }> = ({
+ value,
+ onChange,
+}) => (
+
+ onChange({ ...value, window: id as TimeWindow })}
+ options={WINDOWS}
+ value={value.window}
+ />
+ onChange({ ...value, state: id })} options={STATES} value={value.state} />
+ onChange({ ...value, q: e.target.value })}
+ placeholder="dag, task, model, or input contains…"
+ size="xs"
+ value={value.q}
+ width="220px"
+ />
+ onChange({ ...value, minLatency: e.target.value })}
+ placeholder="min latency (s)"
+ size="xs"
+ value={value.minLatency}
+ width="110px"
+ />
+ onChange({ ...value, minCost: e.target.value })}
+ placeholder="min cost ($)"
+ size="xs"
+ value={value.minCost}
+ width="100px"
+ />
+
+);
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/JsonView.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/JsonView.tsx
new file mode 100644
index 0000000000000..abc6256e02564
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/JsonView.tsx
@@ -0,0 +1,145 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Box, Button, Text } from "@chakra-ui/react";
+import { type FC, useState } from "react";
+
+// Typed, collapsible JSON tree (the Logfire-style "{ 5 items }" viewer) --
+// replaces flat pretty-printed strings everywhere structured data renders.
+
+function valueColor(v: unknown): string {
+ if (typeof v === "string") return "green.fg";
+ if (typeof v === "number") return "blue.fg";
+ if (typeof v === "boolean") return "purple.fg";
+ return "fg.muted";
+}
+
+const JsonNode: FC<{ depth: number; k?: string; v: unknown }> = ({ depth, k, v }) => {
+ const [open, setOpen] = useState(depth < 2);
+
+ if (v === null || typeof v !== "object") {
+ return (
+
+ {k !== undefined && (
+
+ {JSON.stringify(k)}:{" "}
+
+ )}
+
+ {JSON.stringify(v)}
+
+
+ );
+ }
+
+ const isArr = Array.isArray(v);
+ const entries: [string, unknown][] = isArr
+ ? (v as unknown[]).map((x, i) => [String(i), x])
+ : Object.entries(v as Record);
+ const [openBracket, closeBracket] = isArr ? ["[", "]"] : ["{", "}"];
+
+ if (entries.length === 0) {
+ return (
+
+ {k !== undefined && (
+
+ {JSON.stringify(k)}:{" "}
+
+ )}
+
+ {openBracket}
+ {closeBracket}
+
+
+ );
+ }
+
+ return (
+
+ setOpen((o) => !o)} userSelect="none">
+
+ {open ? "▾ " : "▸ "}
+
+ {k !== undefined && (
+
+ {JSON.stringify(k)}:{" "}
+
+ )}
+ {openBracket}
+ {!open && (
+
+ {" "}
+ {entries.length} {isArr ? "items" : "keys"}{" "}
+
+ )}
+ {!open && {closeBracket}}
+
+ {open && (
+ <>
+
+ {entries.map(([ck, cv]) => (
+
+ ))}
+
+ {closeBracket}
+ >
+ )}
+
+ );
+};
+
+export const JsonView: FC<{ value: unknown }> = ({ value }) => {
+ const [copied, setCopied] = useState(false);
+
+ // Plain strings render as text, not as a quoted JSON scalar.
+ if (typeof value === "string") {
+ return (
+
+ {value}
+
+ );
+ }
+
+ // A copy button on `{}` or a two-key object just overlaps the content;
+ // only offer it when there's enough payload for copying to beat retyping.
+ const copyWorthwhile = JSON.stringify(value).length > 60;
+
+ return (
+
+ {copyWorthwhile && (
+
+ )}
+
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/ObservationTree.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/ObservationTree.tsx
new file mode 100644
index 0000000000000..5d4765b911e29
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/ObservationTree.tsx
@@ -0,0 +1,262 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Badge, Box, Stack, Text } from "@chakra-ui/react";
+import { type FC, useEffect, useMemo, useState } from "react";
+
+import { fetchObservationIO } from "src/api";
+import { JsonView } from "src/components/JsonView";
+import type { ObservationIO, ObservationNode } from "src/types";
+import { fmtCost, fmtLatency } from "src/util";
+
+const typeColor: Record = {
+ GENERATION: "purple",
+ SPAN: "gray",
+ TOOL: "green",
+};
+
+const IOBlock: FC<{ label: string; value: unknown }> = ({ label, value }) => (
+
+
+ {label}
+
+
+
+
+
+);
+
+interface TimeBounds {
+ span: number;
+ start: number;
+}
+
+// Proportional duration bar anchored to trace start -- the "where did the
+// time go" view. Both UX reviews independently ranked this the single most
+// important addition: a 32s trace whose spans sum to 5s is invisible without
+// it. Pure client-side; start_time/latency are already in the payload.
+const WaterfallBar: FC<{ bounds: TimeBounds; node: ObservationNode }> = ({ bounds, node }) => {
+ const start = node.start_time ? Date.parse(node.start_time) : NaN;
+ if (!bounds.span || Number.isNaN(start)) return null;
+ const leftPct = Math.min(100, Math.max(0, ((start - bounds.start) / bounds.span) * 100));
+ const widthPct = Math.max(0.8, Math.min(100 - leftPct, (((node.latency ?? 0) * 1000) / bounds.span) * 100));
+ const color =
+ node.type === "GENERATION" ? "purple.solid" : node.type === "TOOL" ? "green.solid" : "gray.solid";
+ return (
+
+
+
+ );
+};
+
+const TreeNode: FC<{
+ bounds: TimeBounds;
+ depth: number;
+ node: ObservationNode;
+ subtree: Map;
+ tree: Map;
+}> = ({ bounds, depth, node, subtree, tree }) => {
+ // All nodes start collapsed: the tree structure + waterfall is the
+ // overview, node detail (input/output) is opt-in. Auto-expanding the
+ // GENERATION root made its IO read as a duplicate of the card's
+ // Prompt/Completion summary blocks.
+ const [opened, setOpened] = useState(false);
+ const [io, setIo] = useState(null);
+ const [ioLoading, setIoLoading] = useState(false);
+
+ // Trace-store mode inlines IO on the node itself (key present, possibly
+ // null); Langfuse mode omits the keys entirely. Key-presence, not
+ // truthiness, is the mode signal -- a store SPAN node with null IO must not
+ // fall through to a lazy fetch that can only 404.
+ const inlineIO = node.input !== undefined || node.output !== undefined;
+
+ // Lazy-fetch this node's input/output the first time it's expanded; the
+ // trace-detail payload deliberately strips IO so unopened nodes cost nothing.
+ useEffect(() => {
+ if (!opened || inlineIO || io !== null || ioLoading) return;
+ setIoLoading(true);
+ fetchObservationIO(node.id)
+ .then(setIo)
+ .catch(() => setIo({ id: node.id, input: null, model_parameters: null, output: null }))
+ .finally(() => setIoLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [opened]);
+
+ const ioInput = inlineIO ? node.input : io?.input;
+ const ioOutput = inlineIO ? node.output : io?.output;
+
+ const children = tree.get(node.id) ?? [];
+ const isError = node.level === "ERROR" || Boolean(node.status_message);
+ const ownInput = node.input_tokens ?? 0;
+ const ownOutput = node.output_tokens ?? 0;
+ const rolled = subtree.get(node.id);
+ // Logfire convention: a parent without its own usage shows the SUM of its
+ // descendants' tokens, marked with ∑ to distinguish rollup from direct usage.
+ const showRollup = ownInput === 0 && ownOutput === 0 && rolled != null && (rolled.in > 0 || rolled.out > 0);
+
+ return (
+
+ setOpened((v) => !v)}
+ px={1}
+ py={0.5}
+ >
+
+ {opened ? "▾" : "▸"}
+
+
+ {node.type ?? "?"}
+
+
+ {node.name ?? node.id}
+
+
+ {fmtLatency(node.latency)}
+ {node.cost != null ? ` · ${fmtCost(node.cost)}` : ""}
+ {ownInput > 0 || ownOutput > 0
+ ? ` · ↗${ownInput} ↙${ownOutput}`
+ : showRollup
+ ? ` · ∑ ↗${rolled.in} ↙${rolled.out}`
+ : node.total_tokens
+ ? ` · ${node.total_tokens} tok`
+ : ""}
+ {node.model ? ` · ${node.model}` : ""}
+
+ {isError && {node.level ?? "ERROR"}}
+
+
+ {opened && (
+
+ {node.status_message && (
+
+ {node.status_message}
+
+ )}
+ {ioLoading && (
+
+ Loading input/output…
+
+ )}
+ {io?.model_parameters && Object.keys(io.model_parameters).length > 0 && (
+
+ )}
+ {ioInput != null && }
+ {ioOutput != null && }
+
+ )}
+ {/* Children render unconditionally: the toggle controls THIS node's
+ detail, never the tree's shape -- collapsing a parent must not hide
+ the structure below it. The rail (borderLeft) lives on the GROUP so
+ it draws one continuous line per nesting level; per-node borders
+ rendered as broken dangling segments. */}
+ {children.length > 0 && (
+
+ {children.map((c) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const ObservationTree: FC<{ observations: ObservationNode[] }> = ({ observations }) => {
+ const tree = useMemo(() => {
+ const ids = new Set(observations.map((o) => o.id));
+ const byParent = new Map();
+ for (const o of observations) {
+ // Langfuse returns "" (not null) for root observations, and a node's
+ // parent may not be in the fetched set at all (e.g. a span outside the
+ // trace's own observations) -- both must render as roots or they'd
+ // silently vanish from the tree.
+ const parent = o.parent_observation_id;
+ const k = parent && ids.has(parent) ? parent : null;
+ const arr = byParent.get(k) ?? [];
+ arr.push(o);
+ byParent.set(k, arr);
+ }
+ for (const arr of byParent.values()) {
+ arr.sort((a, b) => (a.start_time ?? "").localeCompare(b.start_time ?? ""));
+ }
+ return byParent;
+ }, [observations]);
+
+ const bounds = useMemo(() => {
+ const starts = observations
+ .map((o) => (o.start_time ? Date.parse(o.start_time) : NaN))
+ .filter((v) => !Number.isNaN(v));
+ if (starts.length === 0) return { span: 0, start: 0 };
+ const start = Math.min(...starts);
+ const end = Math.max(
+ ...observations.map((o) => {
+ const s = o.start_time ? Date.parse(o.start_time) : NaN;
+ return Number.isNaN(s) ? Number.NEGATIVE_INFINITY : s + (o.latency ?? 0) * 1000;
+ }),
+ );
+ return { span: Math.max(end - start, 1), start };
+ }, [observations]);
+
+ // Per-node descendant token sums for the ∑ rollup on parent rows.
+ const subtree = useMemo(() => {
+ const map = new Map();
+ const compute = (n: ObservationNode): { in: number; out: number } => {
+ const acc = { in: n.input_tokens ?? 0, out: n.output_tokens ?? 0 };
+ for (const c of tree.get(n.id) ?? []) {
+ const s = compute(c);
+ acc.in += s.in;
+ acc.out += s.out;
+ }
+ map.set(n.id, acc);
+ return acc;
+ };
+ for (const r of tree.get(null) ?? []) compute(r);
+ return map;
+ }, [tree]);
+
+ const roots = tree.get(null) ?? [];
+ return (
+
+ {roots.map((o) => (
+
+ ))}
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceCard.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceCard.tsx
new file mode 100644
index 0000000000000..3d094438654cc
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceCard.tsx
@@ -0,0 +1,168 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Badge, Box, Code, Link, Stack, Text } from "@chakra-ui/react";
+import { type FC, type ReactNode, useState } from "react";
+
+import { JsonView } from "src/components/JsonView";
+import { ObservationTree } from "src/components/ObservationTree";
+import type { AirflowRef, TraceSummary } from "src/types";
+import { fmtCost, fmtLatency, fmtTimestamp } from "src/util";
+
+function airflowTaskHref(ref: AirflowRef): string {
+ const baseHref = document.querySelector("head > base")?.getAttribute("href") ?? "";
+ const basePath = new URL(baseHref, globalThis.location.origin).pathname.replace(/\/$/, "");
+ return `${basePath}/dags/${ref.dag_id}/runs/${encodeURIComponent(ref.run_id)}/tasks/${ref.task_id}`;
+}
+
+const Collapsible: FC<{ children: ReactNode; label: string }> = ({ children, label }) => {
+ const [open, setOpen] = useState(false);
+ return (
+
+ setOpen((o) => !o)}
+ userSelect="none"
+ >
+ {open ? "▾" : "▸"} {label}
+
+ {open && children}
+
+ );
+};
+
+const ContentBlock: FC<{ label: string; text: string }> = ({ label, text }) => (
+
+
+ {label}
+
+
+ {text}
+
+
+);
+
+const roleColor: Record = {
+ assistant: "green",
+ system: "gray",
+ tool: "orange",
+ user: "blue",
+};
+
+// Role-ordered conversation (the Logfire LLM-panel presentation) -- renders
+// INSTEAD of the flattened Prompt/Completion blocks when structured messages
+// exist, never alongside them.
+const Conversation: FC<{ messages: { content: string; role: string }[] }> = ({ messages }) => (
+
+
+ Conversation
+
+
+ {messages.map((m, i) => (
+
+
+ {m.role}
+
+
+ {m.content}
+
+
+ ))}
+
+
+);
+
+// Tool-call detail intentionally lives ONLY in the observation tree below
+// (each TOOL node lazy-loads its input/output on expand) -- an inline
+// "Tool calls" section here duplicated the same content twice per trace.
+export const TraceCard: FC<{ summary: TraceSummary }> = ({ summary }) => (
+
+
+
+ AI Trace
+ {summary.trace_id}
+
+
+ {summary.airflow_ref && (
+
+ View task instance ↗
+
+ )}
+ {/* Null in trace-store mode -- there is no external backend to open. */}
+ {summary.langfuse_url && (
+
+ Open in Langfuse ↗
+
+ )}
+
+
+
+ {summary.error != null && (
+
+ ERROR
+
+ )}
+ {summary.model && {summary.model}}
+ {summary.total_tokens != null && {summary.total_tokens} tokens}
+ {summary.observation_count} spans
+ {summary.latency != null && {fmtLatency(summary.latency)}}
+ {summary.cost != null && {fmtCost(summary.cost)}}
+ {summary.timestamp && (
+
+ {fmtTimestamp(summary.timestamp)}
+
+ )}
+
+ {summary.error != null && (
+
+ {summary.error}
+
+ )}
+ {summary.conversation && summary.conversation.length > 0 ? (
+
+ ) : summary.prompt || summary.completion ? (
+ <>
+ {summary.prompt && }
+ {summary.completion && }
+ >
+ ) : (
+
+ No prompt/completion content -- this run either didn't have{" "}
+ capture_content=True set, or content capture is off by default.
+
+ )}
+ {summary.observations.length > 0 && (
+
+
+ Observations ({summary.observations.length})
+
+
+
+ )}
+ {summary.metadata != null && (
+
+
+
+
+
+ )}
+
+);
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceList.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceList.tsx
new file mode 100644
index 0000000000000..6e5bc2ccf4580
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceList.tsx
@@ -0,0 +1,373 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Badge, Box, Button, Code, HStack, Input, Link, Spinner, Stack, Table, Text } from "@chakra-ui/react";
+import { type FC, useCallback, useEffect, useMemo, useState } from "react";
+
+import { ApiError, fetchTraceList } from "src/api";
+import { Aggregates } from "src/components/Aggregates";
+import { AgentsView } from "src/components/AgentsView";
+import { DEFAULT_FILTERS, Filters, type FilterState } from "src/components/Filters";
+import { TraceModal } from "src/components/TraceModal";
+import type { TraceListItem } from "src/types";
+import { fmtCost, fmtLatency, fmtTimestamp, sinceFromWindow } from "src/util";
+
+function uiBasePath(): string {
+ const baseHref = document.querySelector("head > base")?.getAttribute("href") ?? "";
+ const baseUrl = new URL(baseHref, globalThis.location.origin);
+
+ return baseUrl.pathname.replace(/\/$/, "");
+}
+
+function taskDetailHref(item: TraceListItem): string {
+ const runId = encodeURIComponent(item.run_id);
+
+ return `${uiBasePath()}/dags/${item.dag_id}/runs/${runId}/tasks/${item.task_id}/plugin/ai-trace`;
+}
+
+const stateColor: Record = {
+ failed: "red",
+ running: "blue",
+ success: "green",
+ up_for_retry: "orange",
+};
+
+function matchesSearch(item: TraceListItem, needle: string): boolean {
+ if (!needle) return true;
+ const haystack =
+ `${item.dag_id} ${item.task_id} ${item.model ?? ""} ${item.input_preview ?? ""}`.toLowerCase();
+ return haystack.includes(needle.toLowerCase());
+}
+
+// Deep-linkable selection via ?trace= (query param, not a path segment,
+// so opening/closing the pane never remounts the table).
+function readTraceParam(): string | null {
+ return new URLSearchParams(globalThis.location.search).get("trace");
+}
+
+function writeTraceParam(traceId: string | null) {
+ const url = new URL(globalThis.location.href);
+ if (traceId) url.searchParams.set("trace", traceId);
+ else url.searchParams.delete("trace");
+ globalThis.history.replaceState(null, "", url.toString());
+}
+
+const TraceLookup: FC<{ onOpen: (id: string) => void }> = ({ onOpen }) => {
+ const [id, setId] = useState("");
+
+ const submit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = id.trim();
+ if (trimmed) onOpen(trimmed);
+ };
+
+ return (
+
+ );
+};
+
+type SortKey = "cost" | "latency" | "start_date" | "state" | "total_tokens";
+
+function compareBy(key: SortKey, dir: 1 | -1) {
+ return (a: TraceListItem, b: TraceListItem) => {
+ const av = a[key];
+ const bv = b[key];
+ if (av == null && bv == null) return 0;
+ if (av == null) return 1; // nulls last regardless of direction
+ if (bv == null) return -1;
+ if (av < bv) return -dir;
+ if (av > bv) return dir;
+ return 0;
+ };
+}
+
+export const TraceList: FC<{ dagId?: string }> = ({ dagId }) => {
+ const [items, setItems] = useState(null);
+ const [error, setError] = useState(null);
+ const [filters, setFilters] = useState(DEFAULT_FILTERS);
+ const [selectedTraceId, setSelectedTraceId] = useState(readTraceParam);
+ const [refreshNonce, setRefreshNonce] = useState(0);
+ const [sort, setSort] = useState<{ dir: 1 | -1; key: SortKey } | null>(null);
+ const [view, setView] = useState<"agents" | "traces">("traces");
+ const [agentFilter, setAgentFilter] = useState<{ dagId: string; taskId: string } | null>(null);
+
+ const openTrace = useCallback((traceId: string | null) => {
+ setSelectedTraceId(traceId);
+ writeTraceParam(traceId);
+ }, []);
+
+ const since = useMemo(() => sinceFromWindow(filters.window), [filters.window]);
+ const minLatency = filters.minLatency.trim() === "" ? undefined : Number(filters.minLatency);
+ const minCost = filters.minCost.trim() === "" ? undefined : Number(filters.minCost);
+
+ useEffect(() => {
+ // `cancelled` is flipped by this effect's cleanup, which React runs before
+ // the next effect fires. It guards against two races: a slow older request
+ // resolving after a newer one (out-of-order overwrite of `items`), and a
+ // stale error clobbering fresh results. Clearing `error` up front means a
+ // filter change or Refresh always recovers a previously-errored view
+ // rather than leaving it stuck on the error box until remount.
+ let cancelled = false;
+ // Debounce so typing in the min-latency/cost inputs doesn't fire a
+ // request per keystroke; window/state chip clicks just eat the 300ms.
+ const timer = setTimeout(() => {
+ setError(null);
+ setItems(null);
+ fetchTraceList({
+ dagId: dagId ?? agentFilter?.dagId,
+ minCost: minCost != null && !Number.isNaN(minCost) ? minCost : undefined,
+ minLatency: minLatency != null && !Number.isNaN(minLatency) ? minLatency : undefined,
+ since,
+ state: filters.state || undefined,
+ taskId: agentFilter?.taskId,
+ })
+ .then((res) => {
+ if (!cancelled) setItems(res);
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err));
+ });
+ }, 300);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [dagId, agentFilter, since, filters.state, minLatency, minCost, refreshNonce]);
+
+ const filteredItems = useMemo(() => {
+ const matched = (items ?? []).filter((item) => matchesSearch(item, filters.q));
+ if (!sort) return matched;
+ return [...matched].sort(compareBy(sort.key, sort.dir));
+ }, [items, filters.q, sort]);
+
+ const toggleSort = (key: SortKey) =>
+ setSort((prev) => (prev?.key === key ? (prev.dir === -1 ? { dir: 1, key } : null) : { dir: -1, key }));
+
+ // Idle sortable headers carry a faint ⇅ so sortability is discoverable
+ // without hovering; the active column swaps it for the direction arrow.
+ const sortMark = (key: SortKey) => (sort?.key === key ? (sort.dir === -1 ? " ▼" : " ▲") : " ⇅");
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ const showAgents = !dagId && view === "agents";
+
+ return (
+
+
+
+
+
+ {dagId ? `AI Traces — ${dagId}` : "AI Traces"}
+
+ {!dagId && (
+
+ {(["traces", "agents"] as const).map((v) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ {!showAgents && (
+ <>
+
+
+ {agentFilter && (
+
+ )}
+
+ {/* Aggregates are output, not a control -- own line, off the chip row. */}
+ {items !== null && (
+
+
+
+ )}
+ >
+ )}
+
+
+ {showAgents ? (
+ {
+ setAgentFilter({ dagId: agentDagId, taskId: agentTaskId });
+ setView("traces");
+ }}
+ />
+ ) : (
+
+ {items === null ? (
+
+
+
+ ) : filteredItems.length === 0 ? (
+
+ No agent task instances match these filters. Runs using @task.agent{" "}
+ / @task.llm with core tracing on will show up here.
+
+ ) : (
+
+
+
+ {/* Time leads: recency is the primary scanning axis for a
+ trace list; identity (dag/task/run) follows, then state,
+ content, metrics, action. */}
+ toggleSort("start_date")}>
+ Started{sortMark("start_date")}
+
+ {!dagId && Dag}
+ Task
+ Run
+ toggleSort("state")}>
+ State{sortMark("state")}
+
+ Input
+ Model
+ toggleSort("total_tokens")}>
+ Tokens{sortMark("total_tokens")}
+
+ toggleSort("cost")}>
+ Cost{sortMark("cost")}
+
+ toggleSort("latency")}>
+ Latency{sortMark("latency")}
+
+ Task instance
+
+
+
+ {filteredItems.map((item) => (
+ item.trace_id && openTrace(item.trace_id)}
+ >
+
+
+ {fmtTimestamp(item.start_date)}
+
+
+ {!dagId && {item.dag_id}}
+ {item.task_id}
+
+
+ {item.run_id}
+
+
+
+ {item.state ? (
+ {item.state}
+ ) : (
+ "—"
+ )}
+
+
+
+ {item.input_preview ?? "—"}
+
+
+
+ {item.model ? (
+
+ {item.model}
+
+ ) : (
+ "—"
+ )}
+
+
+
+ {item.input_tokens != null && item.output_tokens != null
+ ? `↗${item.input_tokens} ↙${item.output_tokens}`
+ : (item.total_tokens ?? "—")}
+
+
+
+ {fmtCost(item.cost)}
+
+
+ {fmtLatency(item.latency)}
+
+
+ e.stopPropagation()}
+ >
+ View
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ openTrace(null)} traceId={selectedTraceId} />
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceModal.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceModal.tsx
new file mode 100644
index 0000000000000..25f060b01d4fb
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/components/TraceModal.tsx
@@ -0,0 +1,107 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Box, CloseButton, Spinner, Text } from "@chakra-ui/react";
+import { type FC, type ReactNode, useEffect, useState } from "react";
+
+import { ApiError, fetchTraceById } from "src/api";
+import { TraceCard } from "src/components/TraceCard";
+import type { TraceSummary } from "src/types";
+
+// Centered overlay modal. Hand-rolled rather than Chakra's Dialog: the plugin
+// renders inside Airflow's host page, and a plain fixed-position box avoids
+// portal/z-index coupling with the host's own dialog layer.
+const Overlay: FC<{ children: ReactNode; onClose: () => void }> = ({ children, onClose }) => {
+ useEffect(() => {
+ const onEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onEsc);
+ return () => document.removeEventListener("keydown", onEsc);
+ }, [onClose]);
+
+ return (
+
+ e.stopPropagation()}
+ position="relative"
+ shadow="lg"
+ width="90%"
+ >
+
+ {children}
+
+
+ );
+};
+
+export const TraceModal: FC<{ onClose: () => void; traceId: string | null }> = ({ onClose, traceId }) => {
+ const [summary, setSummary] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setSummary(null);
+ setError(null);
+ if (!traceId) return;
+ fetchTraceById(traceId)
+ .then(setSummary)
+ .catch((err: unknown) => {
+ setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err));
+ });
+ }, [traceId]);
+
+ if (!traceId) return null;
+
+ return (
+
+
+ {error ? (
+
+ {error}
+
+ ) : summary ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/dev.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/dev.tsx
new file mode 100644
index 0000000000000..59b7a95dd6887
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/dev.tsx
@@ -0,0 +1,30 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import PluginComponent from "./main";
+
+// Development entry point - mount with mock task instance params for local testing
+createRoot(document.querySelector("#root") as HTMLDivElement).render(
+
+
+ ,
+);
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/main.tsx b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/main.tsx
new file mode 100644
index 0000000000000..74e15a67898f3
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/main.tsx
@@ -0,0 +1,121 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Box, Button, ChakraProvider, Spinner, Stack } from "@chakra-ui/react";
+import type { SystemContext } from "@chakra-ui/react";
+import { type FC, useCallback, useEffect, useState } from "react";
+
+import { ApiError, fetchTraceSummary } from "src/api";
+import { TraceCard } from "src/components/TraceCard";
+import { TraceList } from "src/components/TraceList";
+import type { TraceSummary } from "src/types";
+
+import { localSystem } from "./theme";
+
+export interface PluginComponentProps {
+ dagId?: string;
+ mapIndex?: string;
+ runId?: string;
+ taskId?: string;
+}
+
+const NoTrace: FC<{ message: string }> = ({ message }) => (
+
+ {message}
+
+);
+
+const PluginComponent: FC = ({
+ dagId = "",
+ runId = "",
+ taskId = "",
+ mapIndex: mapIndexProp = "-1",
+}) => {
+ const mapIndex = /^-?\d+$/.test(String(mapIndexProp)) ? parseInt(String(mapIndexProp), 10) : -1;
+ const [summary, setSummary] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const isTaskInstanceContext = Boolean(dagId && runId && taskId);
+
+ const load = useCallback(async () => {
+ if (!isTaskInstanceContext) return;
+ setLoading(true);
+ try {
+ const data = await fetchTraceSummary(dagId, runId, taskId, mapIndex);
+ setSummary(data);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err));
+ setSummary(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [isTaskInstanceContext, dagId, runId, taskId, mapIndex]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ if (!isTaskInstanceContext) {
+ // dagId alone (no runId/taskId) -> the dag-level "Agent traces" tab, scoped
+ // to that dag. All three absent -> the nav-level "AI Traces" destination,
+ // unscoped.
+ return ;
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !summary) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
+
+const WrappedPluginComponent: FC = (props) => {
+ const system = (globalThis as { ChakraUISystem?: SystemContext }).ChakraUISystem ?? localSystem;
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default WrappedPluginComponent;
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/theme.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/theme.ts
new file mode 100644
index 0000000000000..dfb4866cedb58
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/theme.ts
@@ -0,0 +1,22 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createSystem, defaultConfig } from "@chakra-ui/react";
+
+export const localSystem = createSystem(defaultConfig);
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/types.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/types.ts
new file mode 100644
index 0000000000000..b444fa175f2e0
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/types.ts
@@ -0,0 +1,103 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export interface ObservationNode {
+ cost: number | null;
+ id: string;
+ /** Present (possibly null) only in trace-store mode, where IO is inline
+ * because the whole span file was read anyway; absent (undefined) in
+ * Langfuse mode, where IO is lazy-fetched per node on expand. */
+ input?: unknown;
+ input_tokens: number | null;
+ latency: number | null;
+ level: string | null;
+ model: string | null;
+ name: string | null;
+ output?: unknown;
+ output_tokens: number | null;
+ parent_observation_id: string | null;
+ start_time: string | null;
+ status_message: string | null;
+ total_tokens: number | null;
+ type: string | null;
+}
+
+export interface AirflowRef {
+ dag_id: string;
+ map_index: number;
+ run_id: string;
+ task_id: string;
+}
+
+export interface ConversationMessage {
+ content: string;
+ role: string;
+}
+
+export interface AgentItem {
+ dag_id: string;
+ failed: number;
+ last_run: string | null;
+ operator: string | null;
+ runs: number;
+ task_id: string;
+}
+
+export interface ObservationIO {
+ id: string;
+ input: unknown;
+ model_parameters: Record | null;
+ output: unknown;
+}
+
+export interface TraceSummary {
+ airflow_ref?: AirflowRef | null;
+ completion: string | null;
+ conversation?: ConversationMessage[];
+ cost: number | null;
+ error?: string | null;
+ langfuse_url: string | null;
+ latency: number | null;
+ metadata?: unknown;
+ model: string | null;
+ observation_count: number;
+ observations: ObservationNode[];
+ prompt: string | null;
+ timestamp: string | null;
+ total_tokens: number | null;
+ trace_id: string;
+}
+
+export interface TraceListItem {
+ cost: number | null;
+ dag_id: string;
+ input_preview: string | null;
+ input_tokens: number | null;
+ latency: number | null;
+ output_tokens: number | null;
+ map_index: number;
+ model: string | null;
+ operator: string | null;
+ run_id: string;
+ start_date: string | null;
+ state: string | null;
+ task_id: string;
+ total_tokens: number | null;
+ trace_id: string | null;
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/util.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/util.ts
new file mode 100644
index 0000000000000..28a112d3cae37
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/util.ts
@@ -0,0 +1,79 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { TraceListItem } from "src/types";
+
+export type TimeWindow = "1h" | "24h" | "7d" | "all";
+
+export function sinceFromWindow(w: TimeWindow): string | undefined {
+ if (w === "all") return undefined;
+ const ms = w === "1h" ? 3_600_000 : w === "24h" ? 86_400_000 : 7 * 86_400_000;
+ return new Date(Date.now() - ms).toISOString();
+}
+
+export function percentile(values: number[], p: number): number | null {
+ if (values.length === 0) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ // Linear interpolation between closest ranks -- the naive floor-index
+ // approach reports the MAX as p50 at N=2 (caught by review: two traces of
+ // 94ms and 32.4s both showed "32.4s p50").
+ const rank = (p / 100) * (sorted.length - 1);
+ const lo = Math.floor(rank);
+ const hi = Math.ceil(rank);
+ const loVal = sorted[lo] ?? null;
+ const hiVal = sorted[hi] ?? null;
+ if (loVal === null || hiVal === null) return loVal ?? hiVal;
+ return loVal + (hiVal - loVal) * (rank - lo);
+}
+
+export interface Aggregates {
+ count: number;
+ p50Latency: number | null;
+ p95Latency: number | null;
+ totalCost: number;
+}
+
+export function aggregate(items: TraceListItem[]): Aggregates {
+ const latencies = items.map((t) => t.latency).filter((v): v is number => typeof v === "number");
+ const totalCost = items.reduce((sum, t) => sum + (typeof t.cost === "number" ? t.cost : 0), 0);
+ return {
+ count: items.length,
+ p50Latency: percentile(latencies, 50),
+ p95Latency: percentile(latencies, 95),
+ totalCost,
+ };
+}
+
+export function fmtTimestamp(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ return new Date(iso).toLocaleString();
+}
+
+export function fmtLatency(s: number | null | undefined): string {
+ if (s == null) return "—";
+ if (s < 1) return `${Math.round(s * 1000)}ms`;
+ return `${s.toFixed(1)}s`;
+}
+
+export function fmtCost(c: number | null | undefined): string {
+ if (c == null) return "—";
+ if (c === 0) return "$0.0000";
+ if (c < 0.01) return "<$0.01";
+ return `$${c.toFixed(4)}`;
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/vite-env.d.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/vite-env.d.ts
new file mode 100644
index 0000000000000..a1fdcdd1e6fc5
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/src/vite-env.d.ts
@@ -0,0 +1,20 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+///
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.app.json b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.app.json
new file mode 100644
index 0000000000000..4705c6983131f
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "baseUrl": ".",
+ "paths": {
+ "src/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.json b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.json
new file mode 100644
index 0000000000000..b6a01bf3b76ac
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.lib.json" }
+ ]
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.lib.json b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.lib.json
new file mode 100644
index 0000000000000..d423df60f8313
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.lib.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./tsconfig.app.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "noEmit": false,
+ "allowImportingTsExtensions": false
+ },
+ "include": ["src/main.tsx"],
+ "exclude": ["**/*.test.*", "**/*.spec.*"]
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.node.json b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.node.json
new file mode 100644
index 0000000000000..a865d28180fa7
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "baseUrl": ".",
+ "paths": {
+ "src/*": ["./src/*"]
+ }
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/vite.config.ts b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/vite.config.ts
new file mode 100644
index 0000000000000..a70c1d47b0f32
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/plugins/ai_trace_www/vite.config.ts
@@ -0,0 +1,91 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import react from "@vitejs/plugin-react-swc";
+import { resolve } from "node:path";
+import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
+import dts from "vite-plugin-dts";
+import { defineConfig } from "vite";
+
+export default defineConfig(({ command }) => {
+ const isLibraryBuild = command === "build";
+
+ return {
+ base: "./",
+ build: isLibraryBuild
+ ? {
+ chunkSizeWarningLimit: 1600,
+ lib: {
+ entry: resolve("src", "main.tsx"),
+ fileName: "main",
+ formats: ["umd"],
+ name: "AirflowPlugin",
+ },
+ rollupOptions: {
+ external: [
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "@chakra-ui/react",
+ "@emotion/react",
+ ],
+ output: {
+ entryFileNames: "[name].umd.cjs",
+ globals: {
+ react: "React",
+ "react-dom": "ReactDOM",
+ "react/jsx-runtime": "ReactJSXRuntime",
+ "@chakra-ui/react": "ChakraUI",
+ "@emotion/react": "EmotionReact",
+ },
+ },
+ },
+ }
+ : {
+ chunkSizeWarningLimit: 1600,
+ },
+ define: {
+ global: "globalThis",
+ "process.env": "{}",
+ "process.env.NODE_ENV": JSON.stringify("production"),
+ },
+ plugins: [
+ react(),
+ cssInjectedByJsPlugin(),
+ ...(isLibraryBuild
+ ? [
+ dts({
+ include: ["src/main.tsx"],
+ insertTypesEntry: true,
+ outDir: "dist",
+ }),
+ ]
+ : []),
+ ],
+ resolve: { alias: { src: "/src" } },
+ server: {
+ cors: true,
+ proxy: {
+ "/ai-trace": {
+ changeOrigin: true,
+ target: "http://localhost:28080",
+ },
+ },
+ },
+ };
+});
diff --git a/providers/common/ai/tests/unit/common/ai/plugins/test_ai_trace.py b/providers/common/ai/tests/unit/common/ai/plugins/test_ai_trace.py
new file mode 100644
index 0000000000000..fc65f93a58829
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/plugins/test_ai_trace.py
@@ -0,0 +1,582 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import pytest
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS
+
+if not AIRFLOW_V_3_1_PLUS:
+ pytest.skip("AI Trace plugin is only compatible with Airflow >= 3.1.0", allow_module_level=True)
+
+import datetime
+import json
+from typing import TYPE_CHECKING
+from unittest import mock
+
+import time_machine
+from fastapi.testclient import TestClient
+
+from airflow.api_fastapi.app import create_app, purge_cached_app
+from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
+from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
+from airflow.providers.common.ai.plugins.ai_trace import (
+ AITracePlugin,
+ _estimate_generation_cost,
+ _get_map_index,
+ _is_safe_segment,
+ _latest_try_file,
+ _normalize_store_trace,
+ _otlp_attrs,
+ _parse_otlp_lines,
+ _store_span_node,
+ _trace_id_from_context_carrier,
+)
+from airflow.providers.standard.operators.empty import EmptyOperator
+
+from tests_common.test_utils.config import conf_vars
+
+if TYPE_CHECKING:
+ from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import SimpleAuthManager
+
+BASE_URL = "http://testserver"
+VALID_TRACE_ID = "1685a5e38f935f8c261c31575ee15666"
+
+
+# ---------------------------------------------------------------------------
+# OTLP span dict builders (a store file is newline-delimited TracesData JSON)
+# ---------------------------------------------------------------------------
+def _kv(k, v):
+ if isinstance(v, bool):
+ val = {"boolValue": v}
+ elif isinstance(v, int):
+ val = {"intValue": str(v)}
+ else:
+ val = {"stringValue": str(v)}
+ return {"key": k, "value": val}
+
+
+def _span(span_id, parent, name, op, *, start_ns=1_000_000_000, dur_ns=1_000_000, attrs=None, error=None):
+ a = [_kv("gen_ai.operation.name", op)] + [_kv(k, v) for k, v in (attrs or {}).items()]
+ s = {
+ "traceId": VALID_TRACE_ID,
+ "spanId": span_id,
+ "name": name,
+ "kind": 1,
+ "startTimeUnixNano": str(start_ns),
+ "endTimeUnixNano": str(start_ns + dur_ns),
+ "attributes": a,
+ "status": {"code": 2, "message": error} if error else {},
+ }
+ if parent:
+ s["parentSpanId"] = parent
+ return s
+
+
+def _to_jsonl(spans) -> str:
+ return "\n".join(
+ json.dumps({"resourceSpans": [{"scopeSpans": [{"scope": {"name": "pydantic-ai"}, "spans": [s]}]}]})
+ for s in spans
+ )
+
+
+def _agent_trace_spans():
+ """A realistic tree: invoke_agent root (carries totals), 2 chats, 1 tool."""
+ return [
+ _span(
+ "aaaa000000000001",
+ None,
+ "invoke_agent agent",
+ "invoke_agent",
+ attrs={"gen_ai.usage.input_tokens": 134, "gen_ai.usage.output_tokens": 24},
+ ),
+ _span(
+ "aaaa000000000002",
+ "aaaa000000000001",
+ "chat",
+ "chat",
+ start_ns=1_100_000_000,
+ attrs={
+ "gen_ai.request.model": "claude-opus-4-8",
+ "gen_ai.response.model": "claude-opus-4-8",
+ "gen_ai.provider.name": "anthropic",
+ "gen_ai.usage.input_tokens": 64,
+ "gen_ai.usage.output_tokens": 7,
+ "gen_ai.input.messages": json.dumps(
+ [{"role": "user", "parts": [{"type": "text", "content": "Roll a die"}]}]
+ ),
+ },
+ ),
+ _span(
+ "aaaa000000000003",
+ "aaaa000000000001",
+ "execute_tool roll_dice",
+ "execute_tool",
+ start_ns=1_200_000_000,
+ attrs={"gen_ai.tool.call.arguments": json.dumps({"sides": 6}), "gen_ai.tool.call.result": "4"},
+ ),
+ _span(
+ "aaaa000000000004",
+ "aaaa000000000001",
+ "chat",
+ "chat",
+ start_ns=1_300_000_000,
+ attrs={
+ "gen_ai.request.model": "claude-opus-4-8",
+ "gen_ai.response.model": "claude-opus-4-8",
+ "gen_ai.provider.name": "anthropic",
+ "gen_ai.usage.input_tokens": 70,
+ "gen_ai.usage.output_tokens": 17,
+ "gen_ai.output.messages": json.dumps(
+ [{"role": "assistant", "parts": [{"type": "text", "content": "You rolled a 4"}]}]
+ ),
+ },
+ ),
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Security fixes (path traversal + trace-id validation) -- pure logic
+# ---------------------------------------------------------------------------
+class TestSafeSegment:
+ @pytest.mark.parametrize(
+ "seg",
+ ["my_dag", "manual__2026-07-03T22:12:52.056569+00:00", "task.with.dots", "-1", "a b"],
+ )
+ def test_accepts_real_identifiers(self, seg):
+ assert _is_safe_segment(seg) is True
+
+ @pytest.mark.parametrize("seg", ["", ".", "..", "../etc", "a/b", "a\\b", "/etc", "a\x00b"])
+ def test_rejects_traversal_and_separators(self, seg):
+ assert _is_safe_segment(seg) is False
+
+
+class TestLatestTryFileGuards:
+ def test_traversal_cannot_reach_a_real_file_outside_root(self, tmp_path):
+ from airflow.sdk import ObjectStoragePath
+
+ root = tmp_path / "store"
+ root.mkdir()
+ # Plant a real jsonl at EXACTLY the path a "../" coordinate resolves to:
+ # store/../outside/t/-1 == tmp_path/outside/t/-1. Without the guard,
+ # _latest_try_file would iterdir that dir and return the file (escape);
+ # with it, dag_id=".." is rejected and it returns None. Fails loudly if
+ # the guard is removed, rather than passing on a missing directory.
+ escaped = tmp_path / "outside" / "t" / "-1"
+ escaped.mkdir(parents=True)
+ (escaped / "1.jsonl").write_text('{"resourceSpans": []}')
+
+ store = ObjectStoragePath(f"file://{root}")
+ assert _latest_try_file(store, "..", "outside", "t", -1) is None
+
+ # Safe control: a legitimate in-root coordinate still resolves.
+ safe = root / "d" / "r" / "t" / "-1"
+ safe.mkdir(parents=True)
+ (safe / "3.jsonl").write_text('{"resourceSpans": []}')
+ assert _latest_try_file(store, "d", "r", "t", -1).name == "3.jsonl"
+
+
+# ---------------------------------------------------------------------------
+# Store reader / normalizer
+# ---------------------------------------------------------------------------
+class TestStoreSpanNode:
+ @pytest.mark.parametrize(
+ ("op", "expected"),
+ [
+ ("chat", "GENERATION"),
+ ("text_completion", "GENERATION"),
+ ("generate_content", "GENERATION"),
+ ("execute_tool", "TOOL"),
+ ("invoke_agent", "SPAN"),
+ ("something_else", "SPAN"),
+ ],
+ )
+ def test_type_mapping(self, op, expected):
+ node = _store_span_node(_span("s1", None, "n", op))
+ assert node["type"] == expected
+
+ def test_generation_inlines_message_io(self):
+ node = _store_span_node(
+ _span(
+ "s1",
+ None,
+ "chat",
+ "chat",
+ attrs={"gen_ai.input.messages": json.dumps([{"role": "user", "parts": []}])},
+ )
+ )
+ # Store nodes always carry input/output keys (possibly null) -- the
+ # frontend uses key-presence to skip its lazy fetch.
+ assert "input" in node
+ assert "output" in node
+ assert node["input"] == [{"role": "user", "parts": []}]
+
+ def test_tool_inlines_arguments_and_result(self):
+ node = _store_span_node(
+ _span(
+ "s1",
+ None,
+ "execute_tool x",
+ "execute_tool",
+ attrs={"gen_ai.tool.call.arguments": json.dumps({"a": 1}), "gen_ai.tool.call.result": "ok"},
+ )
+ )
+ assert node["input"] == {"a": 1}
+ assert node["output"] == "ok"
+
+ def test_error_span_surfaces_level_and_message(self):
+ node = _store_span_node(_span("s1", None, "chat", "chat", error="kaboom"))
+ assert node["level"] == "ERROR"
+ assert node["status_message"] == "kaboom"
+
+
+class TestNormalizeStoreTrace:
+ def test_tokens_sum_generations_only(self):
+ # invoke_agent root carries totals (134/24) AND the two chats carry
+ # their own (64/7 + 70/17 = 134/24). Counting the root too would double.
+ trace = _normalize_store_trace(_agent_trace_spans())
+ assert trace["total_tokens"] == 134 + 24
+
+ def test_reported_shape_is_store_mode(self):
+ trace = _normalize_store_trace(_agent_trace_spans())
+ assert trace["langfuse_url"] is None
+ assert trace["metadata"] is None
+ assert trace["trace_id"] == VALID_TRACE_ID
+ assert trace["observation_count"] == 4
+ assert trace["model"] == "claude-opus-4-8"
+
+ def test_conversation_built_from_first_generation(self):
+ trace = _normalize_store_trace(_agent_trace_spans())
+ roles = [m["role"] for m in trace["conversation"]]
+ assert "user" in roles
+
+ def test_orphan_parent_treated_as_root(self):
+ # A span whose parent is not in the file must still render (as a root),
+ # never silently vanish.
+ spans = [_span("child", "missingparent", "chat", "chat")]
+ trace = _normalize_store_trace(spans)
+ assert trace["observation_count"] == 1
+
+ def test_error_surfaced_at_trace_level(self):
+ spans = [_span("s1", None, "chat", "chat", error="model failed")]
+ trace = _normalize_store_trace(spans)
+ assert trace["error"] == "model failed"
+
+
+class TestParseOtlpLines:
+ def test_blank_and_invalid_lines_skipped(self):
+ text = "\n".join(["", "not json", _to_jsonl([_span("s1", None, "chat", "chat")]), " "])
+ spans = _parse_otlp_lines(text)
+ assert len(spans) == 1
+ assert spans[0]["spanId"] == "s1"
+
+ def test_empty_text_yields_no_spans(self):
+ assert _parse_otlp_lines("") == []
+
+
+class TestOtlpAttrs:
+ def test_flattens_value_types(self):
+ kvs = [
+ {"key": "s", "value": {"stringValue": "x"}},
+ {"key": "i", "value": {"intValue": "5"}},
+ {"key": "b", "value": {"boolValue": True}},
+ {"key": "arr", "value": {"arrayValue": {"values": [{"intValue": "1"}]}}},
+ {"key": "kv", "value": {"kvlistValue": {"values": [{"key": "n", "value": {"intValue": "2"}}]}}},
+ {"no_key": True},
+ ]
+ out = _otlp_attrs(kvs)
+ assert out == {"s": "x", "i": 5, "b": True, "arr": [1], "kv": {"n": 2}}
+
+
+class TestRoundTrip:
+ def test_encoder_output_parses_back(self):
+ # Encoder (_otlp_json) -> file text -> reader (_parse_otlp_lines/_otlp_attrs).
+ import io
+
+ from opentelemetry.sdk.trace import TracerProvider
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+
+ from airflow.providers.common.ai._otlp_json import OTLPJsonStreamExporter
+
+ buf = io.StringIO()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(OTLPJsonStreamExporter(buf)))
+ tracer = provider.get_tracer("pydantic-ai", "1.99.0")
+ with tracer.start_as_current_span("chat") as s:
+ s.set_attribute("gen_ai.operation.name", "chat")
+ s.set_attribute("gen_ai.usage.input_tokens", 51)
+ provider.shutdown()
+
+ spans = _parse_otlp_lines(buf.getvalue())
+ assert len(spans) == 1
+ attrs = _otlp_attrs(spans[0]["attributes"])
+ assert attrs["gen_ai.usage.input_tokens"] == 51
+ assert attrs["gen_ai.operation.name"] == "chat"
+
+
+# ---------------------------------------------------------------------------
+# Cost estimation
+# ---------------------------------------------------------------------------
+class TestEstimateGenerationCost:
+ def test_known_model_returns_positive_float(self):
+ cost = _estimate_generation_cost(
+ model="claude-opus-4-8",
+ provider="anthropic",
+ input_tokens=1000,
+ output_tokens=500,
+ start_ns=1_000_000_000,
+ )
+ assert isinstance(cost, float)
+ assert cost > 0
+
+ def test_unknown_model_returns_none(self):
+ assert (
+ _estimate_generation_cost(
+ model="test", provider=None, input_tokens=10, output_tokens=5, start_ns=None
+ )
+ is None
+ )
+
+ def test_no_tokens_returns_none(self):
+ assert (
+ _estimate_generation_cost(
+ model="claude-opus-4-8",
+ provider="anthropic",
+ input_tokens=0,
+ output_tokens=0,
+ start_ns=None,
+ )
+ is None
+ )
+
+ def test_missing_library_returns_none(self):
+ with mock.patch("airflow.providers.common.ai.plugins.ai_trace._calc_genai_price", None):
+ assert (
+ _estimate_generation_cost(
+ model="claude-opus-4-8",
+ provider="anthropic",
+ input_tokens=1000,
+ output_tokens=500,
+ start_ns=None,
+ )
+ is None
+ )
+
+
+# ---------------------------------------------------------------------------
+# Small pure helpers
+# ---------------------------------------------------------------------------
+class TestTraceIdFromCarrier:
+ def test_valid_traceparent(self):
+ carrier = {"traceparent": f"00-{VALID_TRACE_ID}-0317ff83e621d28c-00"}
+ assert _trace_id_from_context_carrier(carrier) == VALID_TRACE_ID
+
+ @pytest.mark.parametrize("carrier", [None, {}, {"traceparent": "malformed"}, {"traceparent": "a-b-c"}])
+ def test_invalid_returns_none(self, carrier):
+ assert _trace_id_from_context_carrier(carrier) is None
+
+
+class TestGetMapIndex:
+ @pytest.mark.parametrize(
+ ("raw", "expected"), [("0", 0), ("5", 5), ("-1", -1), ("{MAP_INDEX}", -1), ("", -1)]
+ )
+ def test_parsing(self, raw, expected):
+ assert _get_map_index(raw) == expected
+
+
+class TestPluginShape:
+ def test_registers_one_fastapi_app_and_three_react_apps(self):
+ assert len(AITracePlugin.fastapi_apps) == 1
+ assert AITracePlugin.fastapi_apps[0]["url_prefix"] == "/ai-trace"
+ destinations = {r["destination"] for r in AITracePlugin.react_apps}
+ assert destinations == {"task_instance", "nav", "dag"}
+
+
+# ---------------------------------------------------------------------------
+# Endpoints (app + auth harness, mirrors test_hitl_review)
+# ---------------------------------------------------------------------------
+_AUTH_CONF = {
+ (
+ "core",
+ "auth_manager",
+ ): "airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager",
+ ("core", "lazy_discover_providers"): "false",
+}
+
+
+@pytest.fixture
+def store_dir(tmp_path):
+ """A file:// trace store seeded with one agent trace at a known TI path."""
+ ti_dir = tmp_path / "d" / "r" / "t" / "-1"
+ ti_dir.mkdir(parents=True)
+ (ti_dir / "1.jsonl").write_text(_to_jsonl(_agent_trace_spans()))
+ return tmp_path
+
+
+@pytest.fixture
+def admin_client():
+ with conf_vars(_AUTH_CONF), mock.patch("airflow.settings.LAZY_LOAD_PROVIDERS", False):
+ purge_cached_app()
+ app = create_app()
+ auth_manager: SimpleAuthManager = app.state.auth_manager
+ before = datetime.datetime(2014, 1, 1)
+ after = datetime.datetime.now() + datetime.timedelta(days=1)
+ with time_machine.travel(before, tick=False):
+ token = auth_manager._get_token_signer(
+ expiration_time_in_seconds=(after - before).total_seconds()
+ ).generate(auth_manager.serialize_user(SimpleAuthManagerUser(username="admin", role="admin")))
+ yield TestClient(app, headers={"Authorization": f"Bearer {token}"}, base_url=BASE_URL)
+
+
+@pytest.fixture
+def unauthenticated_client():
+ with conf_vars(_AUTH_CONF), mock.patch("airflow.settings.LAZY_LOAD_PROVIDERS", False):
+ purge_cached_app()
+ yield TestClient(create_app(), base_url=BASE_URL)
+
+
+@pytest.fixture
+def unauthorized_client():
+ with conf_vars(_AUTH_CONF), mock.patch("airflow.settings.LAZY_LOAD_PROVIDERS", False):
+ purge_cached_app()
+ app = create_app()
+ auth_manager: SimpleAuthManager = app.state.auth_manager
+ token = auth_manager._get_token_signer().generate(
+ auth_manager.serialize_user(SimpleAuthManagerUser(username="dummy", role=None))
+ )
+ yield TestClient(app, headers={"Authorization": f"Bearer {token}"}, base_url=BASE_URL)
+
+
+@pytest.mark.db_test
+class TestEndpointAuthorization:
+ SUMMARY_PARAMS = {"dag_id": "d", "run_id": "r", "task_id": "t"}
+
+ @pytest.mark.parametrize(
+ ("method", "path", "params"),
+ [
+ ("get", "/ai-trace/trace-summary", SUMMARY_PARAMS),
+ ("get", "/ai-trace/traces", None),
+ ("get", f"/ai-trace/trace/{VALID_TRACE_ID}", None),
+ ("get", "/ai-trace/observations/obs1", None),
+ ("get", "/ai-trace/agents", None),
+ ],
+ )
+ def test_401_unauthenticated(self, unauthenticated_client, method, path, params):
+ resp = getattr(unauthenticated_client, method)(path, params=params)
+ assert resp.status_code == 401
+
+ @pytest.mark.parametrize(
+ ("path", "params"),
+ [
+ ("/ai-trace/trace-summary", SUMMARY_PARAMS),
+ ("/ai-trace/traces", None),
+ (f"/ai-trace/trace/{VALID_TRACE_ID}", None),
+ ("/ai-trace/observations/obs1", None),
+ ("/ai-trace/agents", None),
+ ],
+ )
+ def test_403_forbidden(self, unauthorized_client, path, params):
+ resp = unauthorized_client.get(path, params=params)
+ assert resp.status_code == 403
+
+ def test_health_needs_no_auth(self, unauthenticated_client):
+ assert unauthenticated_client.get("/ai-trace/health").status_code == 200
+
+
+@pytest.mark.db_test
+class TestStoreModeEndpoints:
+ def test_trace_summary_reads_store_without_touching_connection(self, admin_client, store_dir):
+ with (
+ conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}),
+ mock.patch(
+ "airflow.providers.common.ai.plugins.ai_trace.BaseHook.get_connection", autospec=True
+ ) as get_conn,
+ ):
+ resp = admin_client.get(
+ "/ai-trace/trace-summary", params={"dag_id": "d", "run_id": "r", "task_id": "t"}
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["trace_id"] == VALID_TRACE_ID
+ assert body["langfuse_url"] is None
+ assert body["total_tokens"] == 158
+ get_conn.assert_not_called()
+
+ def test_trace_summary_traversal_rejected(self, admin_client, store_dir):
+ with conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}):
+ resp = admin_client.get(
+ "/ai-trace/trace-summary", params={"dag_id": "..", "run_id": "..", "task_id": "etc"}
+ )
+ assert resp.status_code == 400
+
+ def test_trace_summary_404_when_no_file(self, admin_client, tmp_path):
+ with conf_vars({("common.ai", "trace_store_path"): f"file://{tmp_path}"}):
+ resp = admin_client.get(
+ "/ai-trace/trace-summary", params={"dag_id": "nope", "run_id": "x", "task_id": "y"}
+ )
+ assert resp.status_code == 404
+
+ def test_observations_404_in_store_mode(self, admin_client, store_dir):
+ with conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}):
+ resp = admin_client.get("/ai-trace/observations/anything")
+ assert resp.status_code == 404
+
+ def test_trace_by_id_store_returns_200_for_authorized_dag(self, admin_client, store_dir, dag_maker):
+ # The DAG must exist in the metadata DB for admin's authorized set to
+ # include it (get_authorized_dag_ids intersects with real DAGs); the
+ # store scan then resolves the file's owning DAG ("d") and access passes.
+ with dag_maker("d", serialized=True):
+ EmptyOperator(task_id="t")
+ dag_maker.sync_dagbag_to_db()
+ with conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}):
+ resp = admin_client.get(f"/ai-trace/trace/{VALID_TRACE_ID}")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["trace_id"] == VALID_TRACE_ID
+ assert body["airflow_ref"]["dag_id"] == "d"
+
+ def test_trace_by_id_store_403_when_dag_not_authorized(self, admin_client, store_dir):
+ # The trace resolves to DAG "d", but the user is authorized for no DAG
+ # that includes it -> 403 before any span content is returned.
+ restricted = mock.MagicMock(spec=BaseAuthManager)
+ restricted.get_authorized_dag_ids.return_value = {"other_dag"}
+ with (
+ conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}),
+ mock.patch(
+ "airflow.providers.common.ai.plugins.ai_trace.get_auth_manager",
+ autospec=True,
+ return_value=restricted,
+ ),
+ ):
+ resp = admin_client.get(f"/ai-trace/trace/{VALID_TRACE_ID}")
+ assert resp.status_code == 403
+
+ def test_trace_by_id_store_404_when_trace_absent(self, admin_client, store_dir):
+ other_id = "ffffffffffffffffffffffffffffffff"
+ with conf_vars({("common.ai", "trace_store_path"): f"file://{store_dir}"}):
+ resp = admin_client.get(f"/ai-trace/trace/{other_id}")
+ assert resp.status_code == 404
+
+
+@pytest.mark.db_test
+class TestTraceByIdValidation:
+ @pytest.mark.parametrize(
+ "bad", ["%", "notlongenough", "ZZZZa5e38f935f8c261c31575ee15666", VALID_TRACE_ID + "x"]
+ )
+ def test_non_hex_trace_id_rejected(self, admin_client, bad):
+ resp = admin_client.get(f"/ai-trace/trace/{bad}")
+ assert resp.status_code == 400
diff --git a/providers/common/ai/tests/unit/common/ai/test_otlp_json.py b/providers/common/ai/tests/unit/common/ai/test_otlp_json.py
new file mode 100644
index 0000000000000..5e49e6fc9bb01
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/test_otlp_json.py
@@ -0,0 +1,157 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import base64
+import io
+import json
+
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.trace import Status, StatusCode
+
+from airflow.providers.common.ai._otlp_json import (
+ OTLPJsonStreamExporter,
+ _any_value,
+ encode_traces_data,
+)
+
+
+class TestAnyValue:
+ """OTLP AnyValue encoding rules (spec: intValue is a decimal string, bool before int)."""
+
+ def test_bool_encodes_as_bool_not_int(self):
+ # bool is an int subclass; the isinstance order must catch it first, or
+ # True would wrongly serialize as {"intValue": "1"}.
+ assert _any_value(True) == {"boolValue": True}
+ assert _any_value(False) == {"boolValue": False}
+
+ def test_int_encodes_as_decimal_string(self):
+ assert _any_value(42) == {"intValue": "42"}
+ assert _any_value(-1) == {"intValue": "-1"}
+
+ def test_float_encodes_as_double(self):
+ assert _any_value(1.5) == {"doubleValue": 1.5}
+
+ def test_str_encodes_as_string(self):
+ assert _any_value("hi") == {"stringValue": "hi"}
+
+ def test_bytes_encode_base64(self):
+ assert _any_value(b"ab") == {"bytesValue": base64.b64encode(b"ab").decode()}
+
+ def test_list_encodes_as_arrayvalue(self):
+ assert _any_value([1, "x"]) == {"arrayValue": {"values": [{"intValue": "1"}, {"stringValue": "x"}]}}
+
+ def test_mapping_encodes_as_kvlist(self):
+ assert _any_value({"k": 1}) == {"kvlistValue": {"values": [{"key": "k", "value": {"intValue": "1"}}]}}
+
+
+def _spans_from(build) -> list[dict]:
+ """Run ``build(tracer)`` through the exporter and return the raw span dicts written."""
+ buf = io.StringIO()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(OTLPJsonStreamExporter(buf)))
+ tracer = provider.get_tracer("pydantic-ai", "1.99.0")
+ build(tracer)
+ provider.shutdown()
+ spans: list[dict] = []
+ for line in buf.getvalue().splitlines():
+ if not line.strip():
+ continue
+ data = json.loads(line)
+ for rs in data.get("resourceSpans") or []:
+ for ss in rs.get("scopeSpans") or []:
+ spans.extend(ss.get("spans") or [])
+ return spans
+
+
+class TestEncodeSpans:
+ def test_ids_are_hex_and_parent_links(self):
+ def build(tracer):
+ with tracer.start_as_current_span("root"):
+ with tracer.start_as_current_span("child"):
+ pass
+
+ spans = {s["name"]: s for s in _spans_from(build)}
+ root, child = spans["root"], spans["child"]
+ assert len(root["traceId"]) == 32
+ assert all(c in "0123456789abcdef" for c in root["traceId"])
+ assert len(root["spanId"]) == 16
+ # Root carries no parentSpanId; child points at root; shared trace id.
+ assert "parentSpanId" not in root
+ assert child["parentSpanId"] == root["spanId"]
+ assert child["traceId"] == root["traceId"]
+
+ def test_times_and_kind(self):
+ def build(tracer):
+ with tracer.start_as_current_span("s"):
+ pass
+
+ (span,) = _spans_from(build)
+ # int64 nanos as decimal strings; end after start.
+ assert isinstance(span["startTimeUnixNano"], str)
+ assert int(span["endTimeUnixNano"]) >= int(span["startTimeUnixNano"]) > 0
+ # SDK SpanKind.INTERNAL (0) shifts to OTLP 1 (0 is UNSPECIFIED on the wire).
+ assert span["kind"] == 1
+
+ def test_attributes_round_trip_values(self):
+ def build(tracer):
+ with tracer.start_as_current_span("s") as s:
+ s.set_attribute("gen_ai.usage.input_tokens", 51)
+ s.set_attribute("gen_ai.request.model", "claude-opus-4-8")
+ s.set_attribute("flag", True)
+
+ (span,) = _spans_from(build)
+ attrs = {kv["key"]: kv["value"] for kv in span["attributes"]}
+ assert attrs["gen_ai.usage.input_tokens"] == {"intValue": "51"}
+ assert attrs["gen_ai.request.model"] == {"stringValue": "claude-opus-4-8"}
+ assert attrs["flag"] == {"boolValue": True}
+
+ def test_unset_status_emits_empty_status(self):
+ # A span with no explicit status must not carry a code -- the reader
+ # treats a missing/zero code as non-error.
+ def build(tracer):
+ with tracer.start_as_current_span("s"):
+ pass
+
+ (span,) = _spans_from(build)
+ assert span["status"] == {}
+
+ def test_error_status_carries_code_and_message(self):
+ def build(tracer):
+ with tracer.start_as_current_span("s") as s:
+ s.set_status(Status(StatusCode.ERROR, "boom"))
+
+ (span,) = _spans_from(build)
+ assert span["status"] == {"code": 2, "message": "boom"}
+
+ def test_recorded_exception_becomes_event(self):
+ def build(tracer):
+ with tracer.start_as_current_span("s") as s:
+ try:
+ raise ValueError("nope")
+ except ValueError as e:
+ s.record_exception(e)
+
+ (span,) = _spans_from(build)
+ assert span["events"], "record_exception should produce an event"
+ assert any(ev["name"] == "exception" for ev in span["events"])
+
+
+class TestEncodeTracesDataGrouping:
+ def test_empty_input_yields_empty_resource_spans(self):
+ assert encode_traces_data([]) == {"resourceSpans": []}
diff --git a/scripts/ci/prek/compile_provider_assets.py b/scripts/ci/prek/compile_provider_assets.py
index a0f16fb594fa1..642c6f6de056f 100755
--- a/scripts/ci/prek/compile_provider_assets.py
+++ b/scripts/ci/prek/compile_provider_assets.py
@@ -79,6 +79,35 @@
/ "www"
/ "dist",
"hash": PROVIDERS_ROOT / "common" / "ai" / "www-hash.txt",
+ # common.ai ships two independent plugin bundles (HITL Review under
+ # ``www`` and AI Trace under ``ai_trace_www``); each is compiled with the
+ # same pnpm install + build and must be packaged, so extra bundles are
+ # listed here and built alongside the primary one.
+ "extra_bundles": [
+ {
+ "www": PROVIDERS_ROOT
+ / "common"
+ / "ai"
+ / "src"
+ / "airflow"
+ / "providers"
+ / "common"
+ / "ai"
+ / "plugins"
+ / "ai_trace_www",
+ "dist": PROVIDERS_ROOT
+ / "common"
+ / "ai"
+ / "src"
+ / "airflow"
+ / "providers"
+ / "common"
+ / "ai"
+ / "plugins"
+ / "ai_trace_www"
+ / "dist",
+ },
+ ],
},
}
@@ -106,30 +135,16 @@ def get_directory_hash(directory: Path, skip_path_regexps: list[str]) -> str:
SKIP_PATH_REGEXPS = [".*/node_modules.*", ".*/.pnpm-store.*"]
-def compile_assets(provider_name: str):
- if provider_name not in PROVIDERS_PATHS:
- raise ValueError(
- f"Provider '{provider_name}' is not supported. Supported providers: {list(PROVIDERS_PATHS.keys())}"
- )
- provider_paths = PROVIDERS_PATHS[provider_name]
- www_directory = provider_paths["www"]
- dist_directory = provider_paths["dist"]
- provider_paths["hash"].parent.mkdir(exist_ok=True, parents=True)
- if dist_directory.exists():
- old_hash = provider_paths["hash"].read_text().strip() if provider_paths["hash"].exists() else ""
- new_hash = get_directory_hash(www_directory, skip_path_regexps=SKIP_PATH_REGEXPS)
- if new_hash == old_hash:
- print(f"The '{www_directory}' directory has not changed! Skip regeneration.")
- return
- print(f"The directory has changed, regenerating assets in {www_directory}.")
- print("Old hash: " + old_hash)
- print("New hash: " + new_hash)
- else:
+def _build_bundle(provider_name: str, www_directory: Path, dist_directory: Path) -> None:
+ """Run ``pnpm install --frozen-lockfile`` + ``pnpm build`` for one UI bundle."""
+ if not dist_directory.exists():
shutil.rmtree(dist_directory, ignore_errors=True)
env = os.environ.copy()
env["FORCE_COLOR"] = "true"
for try_num in range(3):
- print(f"### Trying to install {provider_name} dependencies: attempt: {try_num + 1} ###")
+ print(
+ f"### Trying to install {provider_name} deps in {www_directory.name}: attempt {try_num + 1} ###"
+ )
result = subprocess.run(
["pnpm", "install", "--frozen-lockfile"],
cwd=os.fspath(www_directory),
@@ -143,7 +158,35 @@ def compile_assets(provider_name: str):
print(result.stdout + "\n" + result.stderr)
sys.exit(result.returncode)
subprocess.check_call(["pnpm", "build"], cwd=os.fspath(www_directory), env=env)
- new_hash = get_directory_hash(www_directory, skip_path_regexps=SKIP_PATH_REGEXPS)
+
+
+def compile_assets(provider_name: str):
+ if provider_name not in PROVIDERS_PATHS:
+ raise ValueError(
+ f"Provider '{provider_name}' is not supported. Supported providers: {list(PROVIDERS_PATHS.keys())}"
+ )
+ provider_paths = PROVIDERS_PATHS[provider_name]
+ # A provider may ship more than one bundle (e.g. common.ai has HITL Review
+ # and AI Trace); build them all. The change hash covers every source dir so
+ # a change in any bundle triggers a rebuild of the set.
+ bundles = [{"www": provider_paths["www"], "dist": provider_paths["dist"]}]
+ bundles += provider_paths.get("extra_bundles", [])
+ provider_paths["hash"].parent.mkdir(exist_ok=True, parents=True)
+
+ all_dists_exist = all(b["dist"].exists() for b in bundles)
+ new_hash = "".join(get_directory_hash(b["www"], skip_path_regexps=SKIP_PATH_REGEXPS) for b in bundles)
+ if all_dists_exist:
+ old_hash = provider_paths["hash"].read_text().strip() if provider_paths["hash"].exists() else ""
+ if new_hash == old_hash:
+ print(f"The {provider_name!r} UI sources have not changed! Skip regeneration.")
+ return
+ print(f"The {provider_name!r} UI sources changed, regenerating all bundles.")
+ print("Old hash: " + old_hash)
+ print("New hash: " + new_hash)
+
+ for bundle in bundles:
+ _build_bundle(provider_name, bundle["www"], bundle["dist"])
+ new_hash = "".join(get_directory_hash(b["www"], skip_path_regexps=SKIP_PATH_REGEXPS) for b in bundles)
provider_paths["hash"].write_text(new_hash + "\n")
print(f"Assets compiled successfully. New hash: {new_hash}")
diff --git a/scripts/ci/prek/update_providers_dependencies.py b/scripts/ci/prek/update_providers_dependencies.py
index 41b0c4fe79f4d..2a800146ec5dc 100755
--- a/scripts/ci/prek/update_providers_dependencies.py
+++ b/scripts/ci/prek/update_providers_dependencies.py
@@ -212,7 +212,10 @@ def check_if_different_provider_used(file_path: Path) -> None:
for key in sorted(ALL_DEPENDENCIES.keys()):
unique_sorted_dependencies[key]["deps"] = sorted(ALL_DEPENDENCIES[key]["deps"])
unique_sorted_dependencies[key]["devel-deps"] = sorted(ALL_DEPENDENCIES[key]["devel-deps"])
- unique_sorted_dependencies[key]["plugins"] = sorted(ALL_DEPENDENCIES[key]["plugins"])
+ unique_sorted_dependencies[key]["plugins"] = sorted(
+ ALL_DEPENDENCIES[key]["plugins"],
+ key=lambda plugin: plugin["name"] if isinstance(plugin, dict) else plugin,
+ )
unique_sorted_dependencies[key]["cross-providers-deps"] = sorted(
set(ALL_DEPENDENCIES[key]["cross-providers-deps"])
)