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 ( +
+ + setId(e.target.value)} + placeholder="open trace by id…" + size="xs" + value={id} + width="260px" + /> + + +
+ ); +}; + +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"]) )