From 901e8ef30d7f26d7097294634159e8002fef03b9 Mon Sep 17 00:00:00 2001 From: Flowcept CI Bot Date: Fri, 22 May 2026 18:16:39 +0000 Subject: [PATCH 01/47] Flowcept CI Bot: bumping master version --- resources/sample_settings.yaml | 2 +- src/flowcept/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/sample_settings.yaml b/resources/sample_settings.yaml index 01600c0e..f080e99d 100644 --- a/resources/sample_settings.yaml +++ b/resources/sample_settings.yaml @@ -1,4 +1,4 @@ -flowcept_version: 0.10.5 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. +flowcept_version: 0.10.6 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. project: debug: true # Toggle debug mode. This will add a property `debug: true` to all saved data, making it easier to retrieve/delete them later. diff --git a/src/flowcept/version.py b/src/flowcept/version.py index a1465a17..bb3cc65a 100644 --- a/src/flowcept/version.py +++ b/src/flowcept/version.py @@ -7,4 +7,4 @@ # See .github/workflows/version_bumper.py # ✋⚠️⛔❗❗❗ STOP! DANGER!!ONEONEELEVEN! Did you carefully read the warning above?! :) -__version__ = "0.10.5" +__version__ = "0.10.6" From 4058d4f66dff5485bd7fd08bf699042e83bad9ff Mon Sep 17 00:00:00 2001 From: Flowcept CI Bot Date: Thu, 11 Jun 2026 01:45:52 +0000 Subject: [PATCH 02/47] Flowcept CI Bot: bumping master version --- resources/sample_settings.yaml | 2 +- src/flowcept/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/sample_settings.yaml b/resources/sample_settings.yaml index f080e99d..8371056d 100644 --- a/resources/sample_settings.yaml +++ b/resources/sample_settings.yaml @@ -1,4 +1,4 @@ -flowcept_version: 0.10.6 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. +flowcept_version: 0.10.7 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. project: debug: true # Toggle debug mode. This will add a property `debug: true` to all saved data, making it easier to retrieve/delete them later. diff --git a/src/flowcept/version.py b/src/flowcept/version.py index bb3cc65a..3209a62f 100644 --- a/src/flowcept/version.py +++ b/src/flowcept/version.py @@ -7,4 +7,4 @@ # See .github/workflows/version_bumper.py # ✋⚠️⛔❗❗❗ STOP! DANGER!!ONEONEELEVEN! Did you carefully read the warning above?! :) -__version__ = "0.10.6" +__version__ = "0.10.7" From f16e2747c9ed00791911e5f695204462a724a25f Mon Sep 17 00:00:00 2001 From: Renan Souza Date: Thu, 11 Jun 2026 09:06:03 -0400 Subject: [PATCH 03/47] feat: add Flowcept web UI, REST API expansion, SSE streaming, dashboards, and LLM chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Backend — FastAPI webservice (src/flowcept/webservice/) New routers under /api/v1: - campaigns: GET /campaigns, GET /campaigns/{id} — derived from workflow/task grouping by campaign_id (no new collection; aggregation pipeline with Python fallback) - agents: GET /agents, GET /agents/{id}, GET /agents/{id}/tasks — derived from tasks grouped by agent_id / source_agent_id - stats: GET /stats/tasks/summary, POST /stats/timeseries, POST /stats/card_data — shared card-data resolver powering both dashboard cards and the chat make_chart tool - dashboards: full CRUD for declarative JSON dashboard specs (DashboardSpec/Card/CardData); Mongo-backed (new dashboards collection) with FileDashboardStore fallback - stream: GET /stream/tasks, GET /stream/workflows — SSE via sse-starlette, incremental DB polling with float/datetime cursor normalization, ping keepalive, configurable batch - chat: POST /chat — stateless LLM tool-calling loop (bind_tools + stream), emits token/tool_call/tool_result/card/done SSE events; 503 when LLM deps absent New webservice internals: - services/stats.py: task_summary, derive_campaigns, derive_agents, telemetry_timeseries, resolve_card_data (Mongo aggregation path + pandas/Python fallback) - services/streaming.py: poll_new_docs with dual float/datetime cursor via $or; _as_epoch() normalization - services/chat_service.py: run_chat() generator; _build_langchain_tools() wrapping prov_tools; fix for ruff F821 (name before assignment) - services/dashboard_store.py: MongoDashboardStore + FileDashboardStore factory - services/reports.py: provenance_card_response() shared by workflow/campaign routers - schemas/dashboards.py: Pydantic DashboardSpec/Card/CardData/VizSpec/LayoutItem - workflows router: added GET /{id}/provenance_card?format=json|markdown ## Shared provenance tool core (src/flowcept/agents/tools/) - prov_tools.py: plain-Python tool functions (query_tasks, query_workflows, get_task_summary, list_campaigns, list_agents, make_chart, get_dashboard, update_dashboard) guarded by _guarded(); all imports hoisted to module top - db_prov_tools.py: @mcp_flowcept.tool wrappers registering prov_tools on the MCP server so external MCP clients (Claude Code, Codex) get real DB-backed querying; replaces the historical_prov_query stub - prompts/chat_prompts.py: CHAT_SYSTEM_PROMPT with task/workflow schema context, operator allowlist, embedded DashboardSpec JSON schema, few-shot card examples ## MongoDBDAO additions (src/flowcept/commons/daos/docdb_dao/mongodb_dao.py) - raw_pipeline(collection, pipeline): generic aggregation for any collection - save_dashboard / get_dashboard / list_dashboards / delete_dashboard - _dashboards_collection property - Fixed inclusion-projection bug: timestamp:0 exclusion now only added when no inclusion fields are specified (was causing "Cannot do exclusion on field timestamp in inclusion projection" with non-empty projection lists) ## Agent / MQ consumer fixes - flowcept_agent.py: daemon=True server thread; AppStatus.should_exit_event = None reset before each new uvicorn server to avoid sse-starlette event-loop binding crash when a second server starts in the same process - base_agent_context_manager.py: self.start(daemon=True) so the MQ consumer never blocks process exit in test runners - agents/__init__.py: re-exports db_prov_tools symbols ## report/service.py - Extracted public build_provenance_card() from generate_report() (non-breaking; generate_report still calls it) so routers can return card content without writing a file ## configs.py / sample_settings.yaml New config keys: WEBSERVER_UI_ENABLED, WEBSERVER_CORS_ORIGINS, WEBSERVER_SSE_POLL_INTERVAL, WEBSERVER_SSE_MAX_BATCH, WEBSERVER_DASHBOARDS_DIR, WEBSERVER_CHAT; web_server section fully documented in sample_settings.yaml ## CLI (src/flowcept/cli.py) - flowcept --start-webservice [--host --port] - Fixed argparse duplicate-flag crash (added added_args set; start_agent_gui and start_webservice both define --port) ## React SPA (ui/) Stack: React 18 + TypeScript (strict), Vite, Tailwind CSS 4 dark theme, TanStack Router/Query/Table/Virtual, ECharts (tree-shaken), react-grid-layout v2, react-markdown, zustand, zod, @microsoft/fetch-event-source, openapi-typescript. Built assets emit to src/flowcept/webservice/ui_build/ and ship in the wheel. Key files: - api/client.ts, types.ts, queries.ts, sse.ts (useEventStream with cursor resume, backoff, tab-pause) - lib/format.ts: toEpochSec() normalizes both float epoch-seconds and ISO datetime strings (handles BSON datetime serialization from DocumentInserter) - stores/chatStore.ts: zustand chat transcript + panel state - components/charts/: EChart wrapper, GanttChart (custom series), StatusStrip, TelemetryChart - components/tables/DataTable.tsx: TanStack Virtual, div-based - components/dashboard/: spec.ts (zod mirror of dashboards schema), specToOption.ts (declarative CardData → ECharts option), CardRenderer.tsx - components/chat/ChatPanel.tsx: fetchEventSource POST, renders text/tool_call/card parts inline; card events render as ECharts with "pin to dashboard" action - routes/: __root (AppShell + sidebar + chat panel slot), index (overview), campaigns, workflows.$workflowId (tasks/timeline/telemetry/card/raw tabs), tasks.$taskId, objects, agents, dashboards.$dashboardId (grid editor) - ui/README.md: stack, code layout, running instructions, chat setup, MCP notes, test pointers react-grid-layout v2 API changes: no WidthProvider; use useContainerWidth hook; props nested in gridConfig/dragConfig/resizeConfig; removed @types/react-grid-layout (conflicts with v2 built-in types). ## pyproject.toml - webservice extra: fastapi, uvicorn, pyyaml, sse-starlette - wheel artifacts: ui_build/ → flowcept/webservice/ui_build - flowcept[webservice] added to [all] ## Makefile New targets: ui-install, ui-dev, ui-build, ui-checks, webservice ## CI - .github/workflows/ui-checks.yml: Node 22, npm ci + tsc + eslint + vite build (path-filtered on ui/**) - create-release-n-publish.yml: Node setup + make ui-build before hatch build ## .gitignore Added: ui/node_modules/, ui/dist/, ui/.vite/, src/flowcept/webservice/ui_build/ ## OpenAPI Regenerated flowcept-openapi.json / .yaml (43 paths, all new endpoints included); added docs/openapi/scripts/generate_openapi.py ## Tests - tests/webservice/test_webservice_integration.py: 11 new integration tests covering campaigns/agents/stats/prov-card, object versioning, SSE tasks and workflows (real uvicorn via _start_real_server() helper; httpx.stream()), SPA serving, dashboards CRUD, shared prov_tools core, chat 503 path, real LLM tool round-trip - tests/agent/agent_tests.py: test_mcp_db_backed_provenance_tools (real agent + real DB) - tests/webservice/test_webservice_api.py: updated root endpoint assertion to accept HTML (built UI present) or JSON (API-only) - _start_real_server() resets AppStatus.should_exit_event = None before each uvicorn start to prevent sse-starlette event-loop crash in multi-server test runs Commit co-authored by Claude Fable + Sonnet 4.6 --- .../workflows/create-release-n-publish.yml | 10 + .github/workflows/ui-checks.yml | 27 + .gitignore | 11 +- Makefile | 21 + docs/openapi/flowcept-openapi.json | 3517 ++++++++++++ docs/openapi/flowcept-openapi.yaml | 2196 ++++++++ docs/openapi/scripts/generate_openapi.py | 39 + pyproject.toml | 4 + resources/sample_settings.yaml | 8 + src/flowcept/agents/__init__.py | 1 + src/flowcept/agents/flowcept_agent.py | 17 +- src/flowcept/agents/prompts/chat_prompts.py | 30 + src/flowcept/agents/tools/db_prov_tools.py | 47 + src/flowcept/agents/tools/prov_tools.py | 271 + src/flowcept/cli.py | 27 + .../commons/daos/docdb_dao/mongodb_dao.py | 124 +- src/flowcept/configs.py | 14 +- .../agent/base_agent_context_manager.py | 4 +- src/flowcept/report/service.py | 93 +- src/flowcept/webservice/README.md | 229 + src/flowcept/webservice/__init__.py | 1 + src/flowcept/webservice/deps.py | 8 + src/flowcept/webservice/docs/API_CONTRACT.md | 189 + src/flowcept/webservice/docs/ARCHITECTURE.md | 50 + src/flowcept/webservice/main.py | 121 + src/flowcept/webservice/routers/__init__.py | 1 + src/flowcept/webservice/routers/agents.py | 62 + src/flowcept/webservice/routers/campaigns.py | 66 + src/flowcept/webservice/routers/chat.py | 97 + src/flowcept/webservice/routers/dashboards.py | 79 + src/flowcept/webservice/routers/datasets.py | 143 + src/flowcept/webservice/routers/health.py | 17 + src/flowcept/webservice/routers/models.py | 143 + src/flowcept/webservice/routers/objects.py | 183 + src/flowcept/webservice/routers/query.py | 128 + src/flowcept/webservice/routers/stats.py | 89 + src/flowcept/webservice/routers/stream.py | 82 + src/flowcept/webservice/routers/tasks.py | 114 + src/flowcept/webservice/routers/workflows.py | 139 + src/flowcept/webservice/schemas/__init__.py | 1 + src/flowcept/webservice/schemas/common.py | 50 + src/flowcept/webservice/schemas/dashboards.py | 78 + src/flowcept/webservice/services/__init__.py | 1 + .../webservice/services/chat_service.py | 166 + .../webservice/services/dashboard_store.py | 105 + src/flowcept/webservice/services/reports.py | 77 + .../webservice/services/serializers.py | 43 + src/flowcept/webservice/services/sorting.py | 48 + src/flowcept/webservice/services/stats.py | 569 ++ src/flowcept/webservice/services/streaming.py | 94 + tests/agent/agent_tests.py | 47 + tests/webservice/test_imports.py | 7 + tests/webservice/test_webservice_api.py | 581 ++ .../webservice/test_webservice_integration.py | 765 +++ ui/README.md | 158 + ui/index.html | 12 + ui/package-lock.json | 4985 +++++++++++++++++ ui/package.json | 43 + ui/src/api/client.ts | 55 + ui/src/api/queries.ts | 103 + ui/src/api/sse.ts | 61 + ui/src/api/types.ts | 109 + ui/src/components/JsonTree.tsx | 46 + ui/src/components/charts/EChart.tsx | 77 + ui/src/components/charts/GanttChart.tsx | 97 + ui/src/components/charts/StatusStrip.tsx | 61 + ui/src/components/charts/TelemetryChart.tsx | 96 + ui/src/components/chat/ChatPanel.tsx | 164 + ui/src/components/dashboard/CardRenderer.tsx | 100 + ui/src/components/dashboard/spec.ts | 67 + ui/src/components/dashboard/specToOption.ts | 74 + ui/src/components/markdown/Markdown.tsx | 12 + ui/src/components/tables/DataTable.tsx | 110 + ui/src/components/tasks/TaskDrawer.tsx | 106 + ui/src/index.css | 62 + ui/src/lib/format.ts | 63 + ui/src/main.tsx | 26 + ui/src/routeTree.gen.ts | 273 + ui/src/routes/__root.tsx | 71 + ui/src/routes/agents.index.tsx | 41 + ui/src/routes/campaigns.$campaignId.tsx | 87 + ui/src/routes/campaigns.index.tsx | 41 + ui/src/routes/dashboards.$dashboardId.tsx | 294 + ui/src/routes/dashboards.index.tsx | 91 + ui/src/routes/index.tsx | 89 + ui/src/routes/objects.$objectId.tsx | 81 + ui/src/routes/objects.index.tsx | 61 + ui/src/routes/tasks.$taskId.tsx | 121 + ui/src/routes/workflows.$workflowId.tsx | 284 + ui/src/routes/workflows.index.tsx | 56 + ui/src/stores/chatStore.ts | 52 + ui/tsconfig.json | 24 + ui/vite.config.ts | 37 + 93 files changed, 19392 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/ui-checks.yml create mode 100644 docs/openapi/flowcept-openapi.json create mode 100644 docs/openapi/flowcept-openapi.yaml create mode 100644 docs/openapi/scripts/generate_openapi.py create mode 100644 src/flowcept/agents/prompts/chat_prompts.py create mode 100644 src/flowcept/agents/tools/db_prov_tools.py create mode 100644 src/flowcept/agents/tools/prov_tools.py create mode 100644 src/flowcept/webservice/README.md create mode 100644 src/flowcept/webservice/__init__.py create mode 100644 src/flowcept/webservice/deps.py create mode 100644 src/flowcept/webservice/docs/API_CONTRACT.md create mode 100644 src/flowcept/webservice/docs/ARCHITECTURE.md create mode 100644 src/flowcept/webservice/main.py create mode 100644 src/flowcept/webservice/routers/__init__.py create mode 100644 src/flowcept/webservice/routers/agents.py create mode 100644 src/flowcept/webservice/routers/campaigns.py create mode 100644 src/flowcept/webservice/routers/chat.py create mode 100644 src/flowcept/webservice/routers/dashboards.py create mode 100644 src/flowcept/webservice/routers/datasets.py create mode 100644 src/flowcept/webservice/routers/health.py create mode 100644 src/flowcept/webservice/routers/models.py create mode 100644 src/flowcept/webservice/routers/objects.py create mode 100644 src/flowcept/webservice/routers/query.py create mode 100644 src/flowcept/webservice/routers/stats.py create mode 100644 src/flowcept/webservice/routers/stream.py create mode 100644 src/flowcept/webservice/routers/tasks.py create mode 100644 src/flowcept/webservice/routers/workflows.py create mode 100644 src/flowcept/webservice/schemas/__init__.py create mode 100644 src/flowcept/webservice/schemas/common.py create mode 100644 src/flowcept/webservice/schemas/dashboards.py create mode 100644 src/flowcept/webservice/services/__init__.py create mode 100644 src/flowcept/webservice/services/chat_service.py create mode 100644 src/flowcept/webservice/services/dashboard_store.py create mode 100644 src/flowcept/webservice/services/reports.py create mode 100644 src/flowcept/webservice/services/serializers.py create mode 100644 src/flowcept/webservice/services/sorting.py create mode 100644 src/flowcept/webservice/services/stats.py create mode 100644 src/flowcept/webservice/services/streaming.py create mode 100644 tests/webservice/test_imports.py create mode 100644 tests/webservice/test_webservice_api.py create mode 100644 tests/webservice/test_webservice_integration.py create mode 100644 ui/README.md create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/src/api/client.ts create mode 100644 ui/src/api/queries.ts create mode 100644 ui/src/api/sse.ts create mode 100644 ui/src/api/types.ts create mode 100644 ui/src/components/JsonTree.tsx create mode 100644 ui/src/components/charts/EChart.tsx create mode 100644 ui/src/components/charts/GanttChart.tsx create mode 100644 ui/src/components/charts/StatusStrip.tsx create mode 100644 ui/src/components/charts/TelemetryChart.tsx create mode 100644 ui/src/components/chat/ChatPanel.tsx create mode 100644 ui/src/components/dashboard/CardRenderer.tsx create mode 100644 ui/src/components/dashboard/spec.ts create mode 100644 ui/src/components/dashboard/specToOption.ts create mode 100644 ui/src/components/markdown/Markdown.tsx create mode 100644 ui/src/components/tables/DataTable.tsx create mode 100644 ui/src/components/tasks/TaskDrawer.tsx create mode 100644 ui/src/index.css create mode 100644 ui/src/lib/format.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/routeTree.gen.ts create mode 100644 ui/src/routes/__root.tsx create mode 100644 ui/src/routes/agents.index.tsx create mode 100644 ui/src/routes/campaigns.$campaignId.tsx create mode 100644 ui/src/routes/campaigns.index.tsx create mode 100644 ui/src/routes/dashboards.$dashboardId.tsx create mode 100644 ui/src/routes/dashboards.index.tsx create mode 100644 ui/src/routes/index.tsx create mode 100644 ui/src/routes/objects.$objectId.tsx create mode 100644 ui/src/routes/objects.index.tsx create mode 100644 ui/src/routes/tasks.$taskId.tsx create mode 100644 ui/src/routes/workflows.$workflowId.tsx create mode 100644 ui/src/routes/workflows.index.tsx create mode 100644 ui/src/stores/chatStore.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.github/workflows/create-release-n-publish.yml b/.github/workflows/create-release-n-publish.yml index c44a65cb..5652ae2a 100644 --- a/.github/workflows/create-release-n-publish.yml +++ b/.github/workflows/create-release-n-publish.yml @@ -73,6 +73,16 @@ jobs: -H "Authorization: Bearer ${ACCESS_TOKEN}" \ https://api.github.com/repos/${REPOSITORY}/releases + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build the web UI + run: | + make ui-install + make ui-build + - name: Install pypa/build run: >- python -m diff --git a/.github/workflows/ui-checks.yml b/.github/workflows/ui-checks.yml new file mode 100644 index 00000000..6f4df031 --- /dev/null +++ b/.github/workflows/ui-checks.yml @@ -0,0 +1,27 @@ +name: Web UI checks + +on: + pull_request: + paths: + - "ui/**" + - ".github/workflows/ui-checks.yml" + +jobs: + ui-checks: + runs-on: ubuntu-22.04 + if: "!contains(github.event.head_commit.message, 'CI Bot')" + steps: + - uses: actions/checkout@v4 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: ui/package-lock.json + + - name: Install UI dependencies + run: make ui-install + + - name: Build and typecheck the UI + run: make ui-build diff --git a/.gitignore b/.gitignore index 0f0733f3..9b4a5b93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ .idea **/*mlruns* **/*.env* -**/*build* +build/ +docs/_build/ +ui/tsconfig.tsbuildinfo **/*egg* **/*pycache* #**/*dist* @@ -35,4 +37,11 @@ core.* uv.lock examples/flowcept_messages.jsonl agents.md +claude.md tests/instrumentation_tests/ml_tests/PROVENANCE*.md + +# Web UI (Node toolchain + built assets) +ui/node_modules/ +ui/dist/ +ui/.vite/ +src/flowcept/webservice/ui_build/ diff --git a/Makefile b/Makefile index 0de2f52e..16745095 100644 --- a/Makefile +++ b/Makefile @@ -22,12 +22,33 @@ help: @printf "\033[32mdocs\033[0m build HTML documentation using Sphinx\n" @printf "\033[32mchecks\033[0m run ruff linter and formatter checks\n" @printf "\033[32mreformat\033[0m run ruff linter and formatter\n" + @printf "\033[32mui-install\033[0m install web UI dependencies (npm ci)\n" + @printf "\033[32mui-dev\033[0m run the web UI dev server (proxies /api to :5000)\n" + @printf "\033[32mui-build\033[0m build the web UI into src/flowcept/webservice/ui_build\n" + @printf "\033[32mui-checks\033[0m typecheck the web UI\n" + @printf "\033[32mwebservice\033[0m start the Flowcept webservice (REST API + web UI)\n" # Run linter and formatter checks using ruff checks: ruff check src ruff format --check src +.PHONY: ui-install ui-dev ui-build ui-checks webservice +ui-install: + npm ci --prefix ui --no-audit --no-fund + +ui-dev: + npm run dev --prefix ui + +ui-build: + npm run build --prefix ui + +ui-checks: + npm run lint --prefix ui + +webservice: + flowcept --start-webservice + reformat: ruff check src --fix --unsafe-fixes ruff format src diff --git a/docs/openapi/flowcept-openapi.json b/docs/openapi/flowcept-openapi.json new file mode 100644 index 00000000..9d3fd2c3 --- /dev/null +++ b/docs/openapi/flowcept-openapi.json @@ -0,0 +1,3517 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Flowcept Webservice API", + "description": "Read-only REST API for Flowcept provenance data. Provides workflows, tasks, and objects endpoints with query support.", + "version": "1.0.0" + }, + "paths": { + "/api/v1/health/live": { + "get": { + "tags": [ + "health" + ], + "summary": "Live", + "description": "Liveness check.", + "operationId": "live_api_v1_health_live_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Live Api V1 Health Live Get" + } + } + } + } + } + } + }, + "/api/v1/health/ready": { + "get": { + "tags": [ + "health" + ], + "summary": "Ready", + "description": "Readiness check.", + "operationId": "ready_api_v1_health_ready_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Ready Api V1 Health Ready Get" + } + } + } + } + } + } + }, + "/api/v1/campaigns": { + "get": { + "tags": [ + "campaigns" + ], + "summary": "List Campaigns", + "description": "List derived campaign summaries, most recently active first.", + "operationId": "list_campaigns_api_v1_campaigns_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{campaign_id}": { + "get": { + "tags": [ + "campaigns" + ], + "summary": "Get Campaign", + "description": "Get one campaign: derived summary, its workflows, and a task summary.", + "operationId": "get_campaign_api_v1_campaigns__campaign_id__get", + "parameters": [ + { + "name": "campaign_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Campaign Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Campaign Api V1 Campaigns Campaign Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/campaigns/{campaign_id}/provenance_card": { + "get": { + "tags": [ + "campaigns" + ], + "summary": "Get Campaign Provenance Card", + "description": "Get a campaign provenance card as structured JSON or rendered markdown.", + "operationId": "get_campaign_provenance_card_api_v1_campaigns__campaign_id__provenance_card_get", + "parameters": [ + { + "name": "campaign_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Campaign Id" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows": { + "get": { + "tags": [ + "workflows" + ], + "summary": "List Workflows", + "description": "List workflows with optional basic filters.", + "operationId": "list_workflows_api_v1_workflows_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "user", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "parent_workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Workflow Id" + } + }, + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/{workflow_id}": { + "get": { + "tags": [ + "workflows" + ], + "summary": "Get Workflow", + "description": "Get a workflow by id.", + "operationId": "get_workflow_api_v1_workflows__workflow_id__get", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Workflow Api V1 Workflows Workflow Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/query": { + "post": { + "tags": [ + "workflows" + ], + "summary": "Query Workflows", + "description": "Run an advanced read-only workflows query.", + "operationId": "query_workflows_api_v1_workflows_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/{workflow_id}/provenance_card": { + "get": { + "tags": [ + "workflows" + ], + "summary": "Get Workflow Provenance Card", + "description": "Get a workflow provenance card as structured JSON or rendered markdown.", + "operationId": "get_workflow_provenance_card_api_v1_workflows__workflow_id__provenance_card_get", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/{workflow_id}/reports/provenance-card/download": { + "post": { + "tags": [ + "workflows" + ], + "summary": "Download Workflow Provenance Card", + "description": "Generate and download a workflow provenance card markdown file.", + "operationId": "download_workflow_provenance_card_api_v1_workflows__workflow_id__reports_provenance_card_download_post", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks": { + "get": { + "tags": [ + "tasks" + ], + "summary": "List Tasks", + "description": "List tasks with optional basic filters.", + "operationId": "list_tasks_api_v1_tasks_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "parent_task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Task Id" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/{task_id}": { + "get": { + "tags": [ + "tasks" + ], + "summary": "Get Task", + "description": "Get a task by id.", + "operationId": "get_task_api_v1_tasks__task_id__get", + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Task Api V1 Tasks Task Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/by_workflow/{workflow_id}": { + "get": { + "tags": [ + "tasks" + ], + "summary": "List Tasks By Workflow", + "description": "List tasks for a workflow.", + "operationId": "list_tasks_by_workflow_api_v1_tasks_by_workflow__workflow_id__get", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks/query": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Query Tasks", + "description": "Run an advanced read-only task query.", + "operationId": "query_tasks_api_v1_tasks_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects": { + "get": { + "tags": [ + "objects" + ], + "summary": "List Objects", + "description": "List objects with optional basic filters.", + "operationId": "list_objects_api_v1_objects_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "object_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Id" + } + }, + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Type" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}": { + "get": { + "tags": [ + "objects" + ], + "summary": "Get Object", + "description": "Get latest version of an object by id.", + "operationId": "get_object_api_v1_objects__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Object Api V1 Objects Object Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}/versions/{version}": { + "get": { + "tags": [ + "objects" + ], + "summary": "Get Object Version", + "description": "Get a specific object version by id and version number.", + "operationId": "get_object_version_api_v1_objects__object_id__versions__version__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Object Version Api V1 Objects Object Id Versions Version Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}/download": { + "get": { + "tags": [ + "objects" + ], + "summary": "Download Object", + "description": "Download object payload as binary.", + "operationId": "download_object_api_v1_objects__object_id__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}/versions/{version}/download": { + "get": { + "tags": [ + "objects" + ], + "summary": "Download Object Version", + "description": "Download a specific object payload version as binary.", + "operationId": "download_object_version_api_v1_objects__object_id__versions__version__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}/history": { + "get": { + "tags": [ + "objects" + ], + "summary": "Get Object History", + "description": "Get object metadata history (latest-first).", + "operationId": "get_object_history_api_v1_objects__object_id__history_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/query": { + "post": { + "tags": [ + "objects" + ], + "summary": "Query Objects", + "description": "Run an advanced read-only object query.", + "operationId": "query_objects_api_v1_objects_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets": { + "get": { + "tags": [ + "datasets" + ], + "summary": "List Datasets", + "description": "List dataset objects with optional filters.", + "operationId": "list_datasets_api_v1_datasets_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + { + "name": "object_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Id" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets/{object_id}": { + "get": { + "tags": [ + "datasets" + ], + "summary": "Get Dataset", + "description": "Get dataset object metadata by id and optional version.", + "operationId": "get_dataset_api_v1_datasets__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Dataset Api V1 Datasets Object Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets/{object_id}/versions/{version}": { + "get": { + "tags": [ + "datasets" + ], + "summary": "Get Dataset Version", + "description": "Get a specific dataset object version.", + "operationId": "get_dataset_version_api_v1_datasets__object_id__versions__version__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Dataset Version Api V1 Datasets Object Id Versions Version Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets/{object_id}/download": { + "get": { + "tags": [ + "datasets" + ], + "summary": "Download Dataset", + "description": "Download dataset payload as a binary attachment.", + "operationId": "download_dataset_api_v1_datasets__object_id__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets/query": { + "post": { + "tags": [ + "datasets" + ], + "summary": "Query Datasets", + "description": "Run an advanced read-only query for dataset objects.", + "operationId": "query_datasets_api_v1_datasets_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models": { + "get": { + "tags": [ + "models" + ], + "summary": "List Models", + "description": "List ML model objects with optional filters.", + "operationId": "list_models_api_v1_models_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + { + "name": "object_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Id" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Model", + "description": "Get ML model object metadata by id and optional version.", + "operationId": "get_model_api_v1_models__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Model Api V1 Models Object Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}/versions/{version}": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Model Version", + "description": "Get a specific ML model object version.", + "operationId": "get_model_version_api_v1_models__object_id__versions__version__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Model Version Api V1 Models Object Id Versions Version Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}/download": { + "get": { + "tags": [ + "models" + ], + "summary": "Download Model", + "description": "Download ML model payload as a binary attachment.", + "operationId": "download_model_api_v1_models__object_id__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/query": { + "post": { + "tags": [ + "models" + ], + "summary": "Query Models", + "description": "Run an advanced read-only query for ML model objects.", + "operationId": "query_models_api_v1_models_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents": { + "get": { + "tags": [ + "agents" + ], + "summary": "List Agents", + "description": "List derived agent summaries, most recently active first.", + "operationId": "list_agents_api_v1_agents_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}": { + "get": { + "tags": [ + "agents" + ], + "summary": "Get Agent", + "description": "Get one agent's derived summary and per-activity task summary.", + "operationId": "get_agent_api_v1_agents__agent_id__get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Agent Api V1 Agents Agent Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}/tasks": { + "get": { + "tags": [ + "agents" + ], + "summary": "Get Agent Tasks", + "description": "List tasks executed by or sent from an agent.", + "operationId": "get_agent_tasks_api_v1_agents__agent_id__tasks_get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/tasks/summary": { + "get": { + "tags": [ + "stats" + ], + "summary": "Get Task Summary", + "description": "Summarize tasks (status counts, per-activity durations, time range).", + "operationId": "get_task_summary_api_v1_stats_tasks_summary_get", + "parameters": [ + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "agent_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Id" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Task Summary Api V1 Stats Tasks Summary Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/timeseries": { + "post": { + "tags": [ + "stats" + ], + "summary": "Post Timeseries", + "description": "Extract plottable rows of dot-notated fields from tasks.", + "operationId": "post_timeseries_api_v1_stats_timeseries_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeseriesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Post Timeseries Api V1 Stats Timeseries Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/card_data": { + "post": { + "tags": [ + "stats" + ], + "summary": "Post Card Data", + "description": "Resolve a declarative dashboard card data binding into rows.", + "operationId": "post_card_data_api_v1_stats_card_data_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CardDataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Post Card Data Api V1 Stats Card Data Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboards": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "List Dashboards", + "description": "List all stored dashboards.", + "operationId": "list_dashboards_api_v1_dashboards_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "dashboards" + ], + "summary": "Create Dashboard", + "description": "Create a dashboard; the server assigns its id and timestamps.", + "operationId": "create_dashboard_api_v1_dashboards_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardSpec" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Create Dashboard Api V1 Dashboards Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboards/{dashboard_id}": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "Get Dashboard", + "description": "Get a dashboard by id.", + "operationId": "get_dashboard_api_v1_dashboards__dashboard_id__get", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Dashboard Api V1 Dashboards Dashboard Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "dashboards" + ], + "summary": "Update Dashboard", + "description": "Replace a dashboard spec, preserving its id and creation time.", + "operationId": "update_dashboard_api_v1_dashboards__dashboard_id__put", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardSpec" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Dashboard Api V1 Dashboards Dashboard Id Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "dashboards" + ], + "summary": "Delete Dashboard", + "description": "Delete a dashboard by id.", + "operationId": "delete_dashboard_api_v1_dashboards__dashboard_id__delete", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Dashboard Api V1 Dashboards Dashboard Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stream/tasks": { + "get": { + "tags": [ + "stream" + ], + "summary": "Stream Tasks", + "description": "Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent.", + "operationId": "stream_tasks_api_v1_stream_tasks_get", + "parameters": [ + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "agent_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Id" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "name": "poll_interval", + "in": "query", + "required": false, + "schema": { + "type": "number", + "maximum": 60.0, + "minimum": 0.1, + "default": 2.0, + "title": "Poll Interval" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stream/workflows": { + "get": { + "tags": [ + "stream" + ], + "summary": "Stream Workflows", + "description": "Stream new workflows as SSE events, optionally scoped by campaign.", + "operationId": "stream_workflows_api_v1_stream_workflows_get", + "parameters": [ + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "name": "poll_interval", + "in": "query", + "required": false, + "schema": { + "type": "number", + "maximum": 60.0, + "minimum": 0.1, + "default": 2.0, + "title": "Poll Interval" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chat": { + "post": { + "tags": [ + "chat" + ], + "summary": "Chat", + "description": "Answer a provenance chat message, optionally streaming SSE events.\n\nStreaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, ``error``.\nNon-streaming responses collect the same events into\n``{\"message\", \"tool_trace\", \"cards\"}``.", + "operationId": "chat_api_v1_chat_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/query/{scope}": { + "post": { + "tags": [ + "query" + ], + "summary": "Query Scope", + "description": "Run a read-only advanced query over a constrained collection scope.", + "operationId": "query_scope_api_v1_query__scope__post", + "parameters": [ + { + "name": "scope", + "in": "path", + "required": true, + "schema": { + "enum": [ + "workflows", + "tasks", + "objects", + "models", + "datasets" + ], + "type": "string", + "title": "Scope" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AggregationSpec": { + "properties": { + "operator": { + "type": "string", + "enum": [ + "avg", + "sum", + "min", + "max" + ], + "title": "Operator" + }, + "field": { + "type": "string", + "minLength": 1, + "title": "Field" + } + }, + "type": "object", + "required": [ + "operator", + "field" + ], + "title": "AggregationSpec", + "description": "Aggregation operator and source field." + }, + "Card": { + "properties": { + "card_id": { + "type": "string", + "title": "Card Id" + }, + "type": { + "type": "string", + "enum": [ + "chart", + "metric", + "table", + "markdown", + "prov_card" + ], + "title": "Type" + }, + "title": { + "type": "string", + "title": "Title", + "default": "" + }, + "live": { + "type": "boolean", + "title": "Live", + "default": false + }, + "refresh_interval_sec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Refresh Interval Sec" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/CardData" + }, + { + "type": "null" + } + ] + }, + "viz": { + "anyOf": [ + { + "$ref": "#/components/schemas/VizSpec" + }, + { + "type": "null" + } + ] + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + }, + "workflow_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + }, + "campaign_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + "type": "object", + "required": [ + "card_id", + "type" + ], + "title": "Card", + "description": "One dashboard card. Content fields depend on ``type``." + }, + "CardData": { + "properties": { + "source": { + "type": "string", + "enum": [ + "tasks", + "workflows", + "objects" + ], + "title": "Source", + "default": "tasks" + }, + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "group_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Group By" + }, + "metrics": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MetricSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Metrics" + }, + "x": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "sort": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SortSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sort" + }, + "limit": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Limit", + "default": 500 + } + }, + "type": "object", + "title": "CardData", + "description": "Declarative data binding for a card: what to query and how to shape it." + }, + "CardDataRequest": { + "properties": { + "data": { + "$ref": "#/components/schemas/CardData" + }, + "context": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Context" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "CardDataRequest", + "description": "Request body for the declarative card-data resolver." + }, + "ChatMessage": { + "properties": { + "role": { + "type": "string", + "title": "Role" + }, + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "role", + "content" + ], + "title": "ChatMessage", + "description": "One conversation message." + }, + "ChatRequest": { + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/ChatMessage" + }, + "type": "array", + "minItems": 1, + "title": "Messages" + }, + "context": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Context" + }, + "stream": { + "type": "boolean", + "title": "Stream", + "default": true + }, + "allow_dashboard_edit": { + "type": "boolean", + "title": "Allow Dashboard Edit", + "default": false + } + }, + "type": "object", + "required": [ + "messages" + ], + "title": "ChatRequest", + "description": "Chat request: client-passed history plus UI context." + }, + "DashboardSpec": { + "properties": { + "dashboard_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dashboard Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description", + "default": "" + }, + "context": { + "additionalProperties": true, + "type": "object", + "title": "Context" + }, + "cards": { + "items": { + "$ref": "#/components/schemas/Card" + }, + "type": "array", + "title": "Cards" + }, + "layout": { + "items": { + "$ref": "#/components/schemas/LayoutItem" + }, + "type": "array", + "title": "Layout" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "DashboardSpec", + "description": "A complete dashboard: context filter, cards, and layout." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "LayoutItem": { + "properties": { + "card_id": { + "type": "string", + "title": "Card Id" + }, + "x": { + "type": "integer", + "maximum": 11.0, + "minimum": 0.0, + "title": "X" + }, + "y": { + "type": "integer", + "minimum": 0.0, + "title": "Y" + }, + "w": { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0, + "title": "W" + }, + "h": { + "type": "integer", + "minimum": 1.0, + "title": "H" + } + }, + "type": "object", + "required": [ + "card_id", + "x", + "y", + "w", + "h" + ], + "title": "LayoutItem", + "description": "Grid placement of a card in a 12-column layout." + }, + "ListResponse": { + "properties": { + "items": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Items" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "limit": { + "type": "integer", + "title": "Limit" + } + }, + "type": "object", + "required": [ + "items", + "count", + "limit" + ], + "title": "ListResponse", + "description": "Generic list envelope for collection endpoints." + }, + "MetricSpec": { + "properties": { + "field": { + "type": "string", + "title": "Field" + }, + "agg": { + "type": "string", + "enum": [ + "avg", + "sum", + "min", + "max", + "count" + ], + "title": "Agg" + } + }, + "type": "object", + "required": [ + "field", + "agg" + ], + "title": "MetricSpec", + "description": "A single aggregation over a (dot-notated) field." + }, + "ObjectQueryRequest": { + "properties": { + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "projection": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Projection" + }, + "limit": { + "type": "integer", + "maximum": 1000.0, + "minimum": 0.0, + "title": "Limit", + "default": 100 + }, + "sort": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SortSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sort" + }, + "aggregation": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AggregationSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Aggregation" + }, + "remove_json_unserializables": { + "type": "boolean", + "title": "Remove Json Unserializables", + "default": true + }, + "include_data": { + "type": "boolean", + "title": "Include Data", + "default": false + } + }, + "type": "object", + "title": "ObjectQueryRequest", + "description": "Object query payload with optional payload inclusion." + }, + "QueryRequest": { + "properties": { + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "projection": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Projection" + }, + "limit": { + "type": "integer", + "maximum": 1000.0, + "minimum": 0.0, + "title": "Limit", + "default": 100 + }, + "sort": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SortSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sort" + }, + "aggregation": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AggregationSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Aggregation" + }, + "remove_json_unserializables": { + "type": "boolean", + "title": "Remove Json Unserializables", + "default": true + } + }, + "type": "object", + "title": "QueryRequest", + "description": "Read-only query payload." + }, + "SortSpec": { + "properties": { + "field": { + "type": "string", + "minLength": 1, + "title": "Field" + }, + "order": { + "type": "integer", + "enum": [ + 1, + -1 + ], + "title": "Order", + "default": 1 + } + }, + "type": "object", + "required": [ + "field" + ], + "title": "SortSpec", + "description": "Sort field/order pair." + }, + "TimeseriesRequest": { + "properties": { + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "fields": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Fields" + }, + "x": { + "type": "string", + "title": "X", + "default": "started_at" + }, + "limit": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Limit", + "default": 1000 + } + }, + "type": "object", + "required": [ + "fields" + ], + "title": "TimeseriesRequest", + "description": "Request body for telemetry/field timeseries extraction." + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VizSpec": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "line", + "bar", + "pie", + "scatter", + "area", + "heatmap" + ], + "title": "Kind", + "default": "line" + }, + "stacked": { + "type": "boolean", + "title": "Stacked", + "default": false + } + }, + "type": "object", + "title": "VizSpec", + "description": "How a chart card renders its rows." + } + } + } +} \ No newline at end of file diff --git a/docs/openapi/flowcept-openapi.yaml b/docs/openapi/flowcept-openapi.yaml new file mode 100644 index 00000000..62c506d8 --- /dev/null +++ b/docs/openapi/flowcept-openapi.yaml @@ -0,0 +1,2196 @@ +openapi: 3.1.0 +info: + title: Flowcept Webservice API + description: Read-only REST API for Flowcept provenance data. Provides workflows, + tasks, and objects endpoints with query support. + version: 1.0.0 +paths: + /api/v1/health/live: + get: + tags: + - health + summary: Live + description: Liveness check. + operationId: live_api_v1_health_live_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Live Api V1 Health Live Get + /api/v1/health/ready: + get: + tags: + - health + summary: Ready + description: Readiness check. + operationId: ready_api_v1_health_ready_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Ready Api V1 Health Ready Get + /api/v1/campaigns: + get: + tags: + - campaigns + summary: List Campaigns + description: List derived campaign summaries, most recently active first. + operationId: list_campaigns_api_v1_campaigns_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/campaigns/{campaign_id}: + get: + tags: + - campaigns + summary: Get Campaign + description: 'Get one campaign: derived summary, its workflows, and a task summary.' + operationId: get_campaign_api_v1_campaigns__campaign_id__get + parameters: + - name: campaign_id + in: path + required: true + schema: + type: string + title: Campaign Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Campaign Api V1 Campaigns Campaign Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/campaigns/{campaign_id}/provenance_card: + get: + tags: + - campaigns + summary: Get Campaign Provenance Card + description: Get a campaign provenance card as structured JSON or rendered markdown. + operationId: get_campaign_provenance_card_api_v1_campaigns__campaign_id__provenance_card_get + parameters: + - name: campaign_id + in: path + required: true + schema: + type: string + title: Campaign Id + - name: format + in: query + required: false + schema: + type: string + default: json + title: Format + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows: + get: + tags: + - workflows + summary: List Workflows + description: List workflows with optional basic filters. + operationId: list_workflows_api_v1_workflows_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: user + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: User + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: parent_workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Parent Workflow Id + - name: name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Name + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{workflow_id}: + get: + tags: + - workflows + summary: Get Workflow + description: Get a workflow by id. + operationId: get_workflow_api_v1_workflows__workflow_id__get + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Workflow Api V1 Workflows Workflow Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/query: + post: + tags: + - workflows + summary: Query Workflows + description: Run an advanced read-only workflows query. + operationId: query_workflows_api_v1_workflows_query_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/QueryRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{workflow_id}/provenance_card: + get: + tags: + - workflows + summary: Get Workflow Provenance Card + description: Get a workflow provenance card as structured JSON or rendered markdown. + operationId: get_workflow_provenance_card_api_v1_workflows__workflow_id__provenance_card_get + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + - name: format + in: query + required: false + schema: + type: string + default: json + title: Format + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{workflow_id}/reports/provenance-card/download: + post: + tags: + - workflows + summary: Download Workflow Provenance Card + description: Generate and download a workflow provenance card markdown file. + operationId: download_workflow_provenance_card_api_v1_workflows__workflow_id__reports_provenance_card_download_post + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/tasks: + get: + tags: + - tasks + summary: List Tasks + description: List tasks with optional basic filters. + operationId: list_tasks_api_v1_tasks_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: parent_task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Parent Task Id + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: status + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Status + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/tasks/{task_id}: + get: + tags: + - tasks + summary: Get Task + description: Get a task by id. + operationId: get_task_api_v1_tasks__task_id__get + parameters: + - name: task_id + in: path + required: true + schema: + type: string + title: Task Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Task Api V1 Tasks Task Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/tasks/by_workflow/{workflow_id}: + get: + tags: + - tasks + summary: List Tasks By Workflow + description: List tasks for a workflow. + operationId: list_tasks_by_workflow_api_v1_tasks_by_workflow__workflow_id__get + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/tasks/query: + post: + tags: + - tasks + summary: Query Tasks + description: Run an advanced read-only task query. + operationId: query_tasks_api_v1_tasks_query_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/QueryRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects: + get: + tags: + - objects + summary: List Objects + description: List objects with optional basic filters. + operationId: list_objects_api_v1_objects_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: object_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Object Id + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Type + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/{object_id}: + get: + tags: + - objects + summary: Get Object + description: Get latest version of an object by id. + operationId: get_object_api_v1_objects__object_id__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Object Api V1 Objects Object Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/{object_id}/versions/{version}: + get: + tags: + - objects + summary: Get Object Version + description: Get a specific object version by id and version number. + operationId: get_object_version_api_v1_objects__object_id__versions__version__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: path + required: true + schema: + type: integer + title: Version + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Object Version Api V1 Objects Object Id Versions Version Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/{object_id}/download: + get: + tags: + - objects + summary: Download Object + description: Download object payload as binary. + operationId: download_object_api_v1_objects__object_id__download_get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Version + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/{object_id}/versions/{version}/download: + get: + tags: + - objects + summary: Download Object Version + description: Download a specific object payload version as binary. + operationId: download_object_version_api_v1_objects__object_id__versions__version__download_get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: path + required: true + schema: + type: integer + title: Version + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/{object_id}/history: + get: + tags: + - objects + summary: Get Object History + description: Get object metadata history (latest-first). + operationId: get_object_history_api_v1_objects__object_id__history_get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/objects/query: + post: + tags: + - objects + summary: Query Objects + description: Run an advanced read-only object query. + operationId: query_objects_api_v1_objects_query_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectQueryRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/datasets: + get: + tags: + - datasets + summary: List Datasets + description: List dataset objects with optional filters. + operationId: list_datasets_api_v1_datasets_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: object_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Object Id + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/datasets/{object_id}: + get: + tags: + - datasets + summary: Get Dataset + description: Get dataset object metadata by id and optional version. + operationId: get_dataset_api_v1_datasets__object_id__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Version + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Dataset Api V1 Datasets Object Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/datasets/{object_id}/versions/{version}: + get: + tags: + - datasets + summary: Get Dataset Version + description: Get a specific dataset object version. + operationId: get_dataset_version_api_v1_datasets__object_id__versions__version__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: path + required: true + schema: + type: integer + title: Version + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Dataset Version Api V1 Datasets Object Id Versions Version Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/datasets/{object_id}/download: + get: + tags: + - datasets + summary: Download Dataset + description: Download dataset payload as a binary attachment. + operationId: download_dataset_api_v1_datasets__object_id__download_get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Version + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/datasets/query: + post: + tags: + - datasets + summary: Query Datasets + description: Run an advanced read-only query for dataset objects. + operationId: query_datasets_api_v1_datasets_query_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectQueryRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/models: + get: + tags: + - models + summary: List Models + description: List ML model objects with optional filters. + operationId: list_models_api_v1_models_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: task_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Task Id + - name: object_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Object Id + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/models/{object_id}: + get: + tags: + - models + summary: Get Model + description: Get ML model object metadata by id and optional version. + operationId: get_model_api_v1_models__object_id__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Version + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Model Api V1 Models Object Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/models/{object_id}/versions/{version}: + get: + tags: + - models + summary: Get Model Version + description: Get a specific ML model object version. + operationId: get_model_version_api_v1_models__object_id__versions__version__get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: path + required: true + schema: + type: integer + title: Version + - name: include_data + in: query + required: false + schema: + type: boolean + default: false + title: Include Data + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Model Version Api V1 Models Object Id Versions Version Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/models/{object_id}/download: + get: + tags: + - models + summary: Download Model + description: Download ML model payload as a binary attachment. + operationId: download_model_api_v1_models__object_id__download_get + parameters: + - name: object_id + in: path + required: true + schema: + type: string + title: Object Id + - name: version + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Version + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/models/query: + post: + tags: + - models + summary: Query Models + description: Run an advanced read-only query for ML model objects. + operationId: query_models_api_v1_models_query_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectQueryRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/agents: + get: + tags: + - agents + summary: List Agents + description: List derived agent summaries, most recently active first. + operationId: list_agents_api_v1_agents_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/agents/{agent_id}: + get: + tags: + - agents + summary: Get Agent + description: Get one agent's derived summary and per-activity task summary. + operationId: get_agent_api_v1_agents__agent_id__get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Agent Api V1 Agents Agent Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/agents/{agent_id}/tasks: + get: + tags: + - agents + summary: Get Agent Tasks + description: List tasks executed by or sent from an agent. + operationId: get_agent_tasks_api_v1_agents__agent_id__tasks_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/tasks/summary: + get: + tags: + - stats + summary: Get Task Summary + description: Summarize tasks (status counts, per-activity durations, time range). + operationId: get_task_summary_api_v1_stats_tasks_summary_get + parameters: + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Task Summary Api V1 Stats Tasks Summary Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/timeseries: + post: + tags: + - stats + summary: Post Timeseries + description: Extract plottable rows of dot-notated fields from tasks. + operationId: post_timeseries_api_v1_stats_timeseries_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TimeseriesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Post Timeseries Api V1 Stats Timeseries Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/card_data: + post: + tags: + - stats + summary: Post Card Data + description: Resolve a declarative dashboard card data binding into rows. + operationId: post_card_data_api_v1_stats_card_data_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CardDataRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Post Card Data Api V1 Stats Card Data Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/dashboards: + get: + tags: + - dashboards + summary: List Dashboards + description: List all stored dashboards. + operationId: list_dashboards_api_v1_dashboards_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + post: + tags: + - dashboards + summary: Create Dashboard + description: Create a dashboard; the server assigns its id and timestamps. + operationId: create_dashboard_api_v1_dashboards_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardSpec' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Create Dashboard Api V1 Dashboards Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/dashboards/{dashboard_id}: + get: + tags: + - dashboards + summary: Get Dashboard + description: Get a dashboard by id. + operationId: get_dashboard_api_v1_dashboards__dashboard_id__get + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Dashboard Api V1 Dashboards Dashboard Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - dashboards + summary: Update Dashboard + description: Replace a dashboard spec, preserving its id and creation time. + operationId: update_dashboard_api_v1_dashboards__dashboard_id__put + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardSpec' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Update Dashboard Api V1 Dashboards Dashboard Id Put + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - dashboards + summary: Delete Dashboard + description: Delete a dashboard by id. + operationId: delete_dashboard_api_v1_dashboards__dashboard_id__delete + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Delete Dashboard Api V1 Dashboards Dashboard Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stream/tasks: + get: + tags: + - stream + summary: Stream Tasks + description: Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent. + operationId: stream_tasks_api_v1_stream_tasks_get + parameters: + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: since + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Since + - name: poll_interval + in: query + required: false + schema: + type: number + maximum: 60.0 + minimum: 0.1 + default: 2.0 + title: Poll Interval + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stream/workflows: + get: + tags: + - stream + summary: Stream Workflows + description: Stream new workflows as SSE events, optionally scoped by campaign. + operationId: stream_workflows_api_v1_stream_workflows_get + parameters: + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: since + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Since + - name: poll_interval + in: query + required: false + schema: + type: number + maximum: 60.0 + minimum: 0.1 + default: 2.0 + title: Poll Interval + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/chat: + post: + tags: + - chat + summary: Chat + description: 'Answer a provenance chat message, optionally streaming SSE events. + + + Streaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, + ``error``. + + Non-streaming responses collect the same events into + + ``{"message", "tool_trace", "cards"}``.' + operationId: chat_api_v1_chat_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChatRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/query/{scope}: + post: + tags: + - query + summary: Query Scope + description: Run a read-only advanced query over a constrained collection scope. + operationId: query_scope_api_v1_query__scope__post + parameters: + - name: scope + in: path + required: true + schema: + enum: + - workflows + - tasks + - objects + - models + - datasets + type: string + title: Scope + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectQueryRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + AggregationSpec: + properties: + operator: + type: string + enum: + - avg + - sum + - min + - max + title: Operator + field: + type: string + minLength: 1 + title: Field + type: object + required: + - operator + - field + title: AggregationSpec + description: Aggregation operator and source field. + Card: + properties: + card_id: + type: string + title: Card Id + type: + type: string + enum: + - chart + - metric + - table + - markdown + - prov_card + title: Type + title: + type: string + title: Title + default: '' + live: + type: boolean + title: Live + default: false + refresh_interval_sec: + anyOf: + - type: number + - type: 'null' + title: Refresh Interval Sec + data: + anyOf: + - $ref: '#/components/schemas/CardData' + - type: 'null' + viz: + anyOf: + - $ref: '#/components/schemas/VizSpec' + - type: 'null' + content: + anyOf: + - type: string + - type: 'null' + title: Content + workflow_id: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + campaign_id: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + type: object + required: + - card_id + - type + title: Card + description: One dashboard card. Content fields depend on ``type``. + CardData: + properties: + source: + type: string + enum: + - tasks + - workflows + - objects + title: Source + default: tasks + filter: + additionalProperties: true + type: object + title: Filter + group_by: + anyOf: + - type: string + - type: 'null' + title: Group By + metrics: + anyOf: + - items: + $ref: '#/components/schemas/MetricSpec' + type: array + - type: 'null' + title: Metrics + x: + anyOf: + - type: string + - type: 'null' + title: X + y: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Y + sort: + anyOf: + - items: + $ref: '#/components/schemas/SortSpec' + type: array + - type: 'null' + title: Sort + limit: + type: integer + maximum: 5000.0 + minimum: 1.0 + title: Limit + default: 500 + type: object + title: CardData + description: 'Declarative data binding for a card: what to query and how to + shape it.' + CardDataRequest: + properties: + data: + $ref: '#/components/schemas/CardData' + context: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Context + type: object + required: + - data + title: CardDataRequest + description: Request body for the declarative card-data resolver. + ChatMessage: + properties: + role: + type: string + title: Role + content: + type: string + title: Content + type: object + required: + - role + - content + title: ChatMessage + description: One conversation message. + ChatRequest: + properties: + messages: + items: + $ref: '#/components/schemas/ChatMessage' + type: array + minItems: 1 + title: Messages + context: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Context + stream: + type: boolean + title: Stream + default: true + allow_dashboard_edit: + type: boolean + title: Allow Dashboard Edit + default: false + type: object + required: + - messages + title: ChatRequest + description: 'Chat request: client-passed history plus UI context.' + DashboardSpec: + properties: + dashboard_id: + anyOf: + - type: string + - type: 'null' + title: Dashboard Id + name: + type: string + title: Name + description: + type: string + title: Description + default: '' + context: + additionalProperties: true + type: object + title: Context + cards: + items: + $ref: '#/components/schemas/Card' + type: array + title: Cards + layout: + items: + $ref: '#/components/schemas/LayoutItem' + type: array + title: Layout + created_at: + anyOf: + - type: string + - type: 'null' + title: Created At + updated_at: + anyOf: + - type: string + - type: 'null' + title: Updated At + type: object + required: + - name + title: DashboardSpec + description: 'A complete dashboard: context filter, cards, and layout.' + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + LayoutItem: + properties: + card_id: + type: string + title: Card Id + x: + type: integer + maximum: 11.0 + minimum: 0.0 + title: X + y: + type: integer + minimum: 0.0 + title: Y + w: + type: integer + maximum: 12.0 + minimum: 1.0 + title: W + h: + type: integer + minimum: 1.0 + title: H + type: object + required: + - card_id + - x + - y + - w + - h + title: LayoutItem + description: Grid placement of a card in a 12-column layout. + ListResponse: + properties: + items: + items: + additionalProperties: true + type: object + type: array + title: Items + count: + type: integer + title: Count + limit: + type: integer + title: Limit + type: object + required: + - items + - count + - limit + title: ListResponse + description: Generic list envelope for collection endpoints. + MetricSpec: + properties: + field: + type: string + title: Field + agg: + type: string + enum: + - avg + - sum + - min + - max + - count + title: Agg + type: object + required: + - field + - agg + title: MetricSpec + description: A single aggregation over a (dot-notated) field. + ObjectQueryRequest: + properties: + filter: + additionalProperties: true + type: object + title: Filter + projection: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Projection + limit: + type: integer + maximum: 1000.0 + minimum: 0.0 + title: Limit + default: 100 + sort: + anyOf: + - items: + $ref: '#/components/schemas/SortSpec' + type: array + - type: 'null' + title: Sort + aggregation: + anyOf: + - items: + $ref: '#/components/schemas/AggregationSpec' + type: array + - type: 'null' + title: Aggregation + remove_json_unserializables: + type: boolean + title: Remove Json Unserializables + default: true + include_data: + type: boolean + title: Include Data + default: false + type: object + title: ObjectQueryRequest + description: Object query payload with optional payload inclusion. + QueryRequest: + properties: + filter: + additionalProperties: true + type: object + title: Filter + projection: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Projection + limit: + type: integer + maximum: 1000.0 + minimum: 0.0 + title: Limit + default: 100 + sort: + anyOf: + - items: + $ref: '#/components/schemas/SortSpec' + type: array + - type: 'null' + title: Sort + aggregation: + anyOf: + - items: + $ref: '#/components/schemas/AggregationSpec' + type: array + - type: 'null' + title: Aggregation + remove_json_unserializables: + type: boolean + title: Remove Json Unserializables + default: true + type: object + title: QueryRequest + description: Read-only query payload. + SortSpec: + properties: + field: + type: string + minLength: 1 + title: Field + order: + type: integer + enum: + - 1 + - -1 + title: Order + default: 1 + type: object + required: + - field + title: SortSpec + description: Sort field/order pair. + TimeseriesRequest: + properties: + filter: + additionalProperties: true + type: object + title: Filter + fields: + items: + type: string + type: array + title: Fields + x: + type: string + title: X + default: started_at + limit: + type: integer + maximum: 5000.0 + minimum: 1.0 + title: Limit + default: 1000 + type: object + required: + - fields + title: TimeseriesRequest + description: Request body for telemetry/field timeseries extraction. + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + VizSpec: + properties: + kind: + type: string + enum: + - line + - bar + - pie + - scatter + - area + - heatmap + title: Kind + default: line + stacked: + type: boolean + title: Stacked + default: false + type: object + title: VizSpec + description: How a chart card renders its rows. diff --git a/docs/openapi/scripts/generate_openapi.py b/docs/openapi/scripts/generate_openapi.py new file mode 100644 index 00000000..2d8ffbbe --- /dev/null +++ b/docs/openapi/scripts/generate_openapi.py @@ -0,0 +1,39 @@ +"""Generate and persist OpenAPI artifacts for Flowcept webservice.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[3] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from flowcept.webservice.main import app + + +def main() -> None: + outdir = Path("docs/openapi") + outdir.mkdir(parents=True, exist_ok=True) + + openapi_schema = app.openapi() + + json_path = outdir / "flowcept-openapi.json" + json_path.write_text(json.dumps(openapi_schema, indent=2), encoding="utf-8") + + yaml_path = outdir / "flowcept-openapi.yaml" + try: + import yaml + except Exception: + yaml_path.write_text( + "# Install pyyaml to generate YAML from the OpenAPI JSON artifact.\n", + encoding="utf-8", + ) + else: + yaml_path.write_text(yaml.safe_dump(openapi_schema, sort_keys=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 7a5c8439..fe12170c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ redis = ["redis"] lmdb = ["lmdb"] telemetry = ["psutil>=6.1.1", "py-cpuinfo"] extras = ["flowcept[redis]", "flowcept[telemetry]", "flowcept[mongo]", "GitPython", "pandas", "flask-restful", "requests"] +webservice = ["fastapi", "uvicorn", "pyyaml", "sse-starlette"] analytics = ["seaborn", "plotly", "scipy", "matplotlib"] mongo = ["pymongo", "pyarrow"] @@ -97,6 +98,7 @@ ml_dev = [ all = [ "flowcept[telemetry]", "flowcept[extras]", + "flowcept[webservice]", "flowcept[redis]", "flowcept[lmdb]", "flowcept[mongo]", @@ -126,6 +128,8 @@ convention = "numpy" [tool.hatch.build.targets.wheel] packages = ["src/flowcept"] +# Built web UI assets (created by `make ui-build` into the package dir); gitignored but shipped. +artifacts = ["src/flowcept/webservice/ui_build/**"] [tool.hatch.build.targets.wheel.force-include] "resources/sample_settings.yaml" = "resources/sample_settings.yaml" diff --git a/resources/sample_settings.yaml b/resources/sample_settings.yaml index d3454419..bd9f9256 100644 --- a/resources/sample_settings.yaml +++ b/resources/sample_settings.yaml @@ -69,6 +69,11 @@ kv_db: web_server: host: 0.0.0.0 port: 5000 + ui_enabled: true # Serve the built web UI (if assets are present) at the root path. + cors_origins: [] # Extra allowed CORS origins; empty disables the CORS middleware (UI is same-origin). + sse_poll_interval_sec: 2.0 # How often live (SSE) endpoints poll the DB for new data. + sse_max_batch: 500 # Max records pushed per SSE event. + dashboards_dir: ~/.flowcept/dashboards # Dashboard JSON storage when MongoDB is disabled. sys_metadata: environment_id: "laptop" # We use this to keep track of the environment used to run an experiment. Typical values include the cluster name, but it can be anything that you think will help identify your experimentation environment. @@ -99,6 +104,9 @@ agent: model_kwargs: {} audio_enabled: false debug: true + chat_enabled: true # Enable the /api/v1/chat endpoint (requires the llm_agent extra and LLM settings above). + chat_max_tool_iterations: 5 # Max LLM tool-calling iterations per chat message. + chat_max_query_limit: 1000 # Hard cap for records returned by chat LLM query tools. databases: diff --git a/src/flowcept/agents/__init__.py b/src/flowcept/agents/__init__.py index e18b0f5e..ac9a58d5 100644 --- a/src/flowcept/agents/__init__.py +++ b/src/flowcept/agents/__init__.py @@ -3,3 +3,4 @@ from flowcept.agents.tools.general_tools import * from flowcept.agents.tools.in_memory_queries.in_memory_queries_tools import * +from flowcept.agents.tools.db_prov_tools import * diff --git a/src/flowcept/agents/flowcept_agent.py b/src/flowcept/agents/flowcept_agent.py index 409cab9e..9cd3019c 100644 --- a/src/flowcept/agents/flowcept_agent.py +++ b/src/flowcept/agents/flowcept_agent.py @@ -90,6 +90,14 @@ def _load_buffer_once(self) -> int: def _run_server(self): """Run the MCP server (blocking call).""" + try: + # sse-starlette keeps a module-level exit Event bound to the first event loop that + # served SSE; reset it so this server's fresh loop can serve SSE in the same process. + from sse_starlette.sse import AppStatus + + AppStatus.should_exit_event = None + except ImportError: + pass config = uvicorn.Config(mcp_flowcept.streamable_http_app, host=AGENT_HOST, port=AGENT_PORT, lifespan="on") self._server = uvicorn.Server(config) self._server.run() @@ -109,17 +117,24 @@ def start(self): else: self._load_buffer_once() - self._server_thread = Thread(target=self._run_server, daemon=False) + # Daemon thread so the hosting process can always exit (e.g., test runners); + # long-running deployments block explicitly via wait(). + self._server_thread = Thread(target=self._run_server, daemon=True) self._server_thread.start() self.logger.info(f"Flowcept agent server started on {AGENT_HOST}:{AGENT_PORT}") return self def stop(self): """Stop the agent server and wait briefly for shutdown.""" + if self._server is None and self._server_thread is not None: + # The server object is created inside the thread; give it a moment to appear. + self._server_thread.join(timeout=1) if self._server is not None: self._server.should_exit = True if self._server_thread is not None: self._server_thread.join(timeout=5) + if self._server_thread.is_alive(): + self.logger.warning("Agent server thread did not stop within 5s; continuing shutdown.") def wait(self): """Block until the server thread exits.""" diff --git a/src/flowcept/agents/prompts/chat_prompts.py b/src/flowcept/agents/prompts/chat_prompts.py new file mode 100644 index 00000000..b2e1c26e --- /dev/null +++ b/src/flowcept/agents/prompts/chat_prompts.py @@ -0,0 +1,30 @@ +"""System prompt for the webservice provenance chat.""" + +CHAT_SYSTEM_PROMPT = """You are the Flowcept provenance assistant, embedded in Flowcept's web UI. +Flowcept captures workflow provenance: campaigns group workflows; workflows contain tasks; +tasks record used (inputs), generated (outputs), status, timings, telemetry, and host info; +binary artifacts (datasets, ML models) are stored as versioned objects. + +Key task fields: task_id, workflow_id, campaign_id, activity_id (function name), status +(FINISHED/ERROR/RUNNING), started_at, ended_at, used.*, generated.*, telemetry_at_start/end +(cpu, memory, disk, network, process, gpu), hostname, agent_id, tags. +Key workflow fields: workflow_id, name, campaign_id, user, utc_timestamp. + +You have tools to query this data. Rules: +- Use the tools to answer data questions; never invent values. Quote real numbers from results. +- Filters are Mongo-style; allowed operators: $and $or $nor $not $exists $eq $ne $gt $gte $lt + $lte $in $nin $regex. +- When the user context includes workflow_id/campaign_id, scope your queries with it. +- Prefer get_task_summary for aggregate questions (counts, durations) over fetching all tasks. +- When asked for a chart/plot, call make_chart with a declarative card spec: + {"card_id": "", "type": "chart", "title": "...", + "data": {"source": "tasks", "filter": {...}, "group_by": "", + "metrics": [{"field": "", "agg": "avg|sum|min|max|count"}] + OR "x": "started_at", "y": ["telemetry_at_end.cpu.percent_all"]}, + "viz": {"kind": "bar|line|pie|scatter|area"}} + The UI renders the chart from the tool result; afterwards summarize the insight in one or + two sentences. +- To modify the user's dashboard (only when asked), call get_dashboard, then update_dashboard + with the complete revised spec; explain what changed. +- Be concise. Use markdown tables for tabular answers. State filters you used. +""" diff --git a/src/flowcept/agents/tools/db_prov_tools.py b/src/flowcept/agents/tools/db_prov_tools.py new file mode 100644 index 00000000..e81f8bb9 --- /dev/null +++ b/src/flowcept/agents/tools/db_prov_tools.py @@ -0,0 +1,47 @@ +"""MCP adapters exposing the shared provenance tool core to external agent clients. + +Thin ``@mcp.tool`` wrappers around :mod:`flowcept.agents.tools.prov_tools`, giving MCP +clients (Claude Code, Codex, etc.) real DB-backed provenance querying — the same tool +core used by the webservice chat. +""" + +from typing import Any, Dict, List, Optional + +from flowcept.agents.agents_utils import ToolResult +from flowcept.agents.flowcept_ctx_manager import mcp_flowcept +from flowcept.agents.tools import prov_tools + + +@mcp_flowcept.tool() +def query_provenance_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[List[str]] = None, + limit: int = 100, + sort: Optional[List[Dict[str, Any]]] = None, +) -> ToolResult: + """Query task provenance records in the database with a Mongo-style filter.""" + return prov_tools.query_tasks(filter=filter, projection=projection, limit=limit, sort=sort) + + +@mcp_flowcept.tool() +def query_provenance_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> ToolResult: + """Query workflow provenance records in the database with a Mongo-style filter.""" + return prov_tools.query_workflows(filter=filter, limit=limit) + + +@mcp_flowcept.tool() +def get_provenance_task_summary(filter: Optional[Dict[str, Any]] = None) -> ToolResult: + """Summarize tasks matching a filter: status counts, per-activity durations, time range.""" + return prov_tools.get_task_summary(filter=filter) + + +@mcp_flowcept.tool() +def list_provenance_campaigns() -> ToolResult: + """List derived campaign summaries (campaigns group workflows and tasks).""" + return prov_tools.list_campaigns() + + +@mcp_flowcept.tool() +def list_provenance_agents() -> ToolResult: + """List derived agent summaries (agents observed in task provenance).""" + return prov_tools.list_agents() diff --git a/src/flowcept/agents/tools/prov_tools.py b/src/flowcept/agents/tools/prov_tools.py new file mode 100644 index 00000000..e69adef0 --- /dev/null +++ b/src/flowcept/agents/tools/prov_tools.py @@ -0,0 +1,271 @@ +"""Shared provenance tool core. + +Plain-Python functions over the provenance DB used by BOTH the webservice chat +(`/api/v1/chat`, via langchain tool wrappers) and the MCP agent (via ``@mcp.tool`` +wrappers), so the two LLM surfaces never drift apart. All results follow the +``ToolResult`` convention. No web-framework imports here. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from datetime import datetime, timezone + +from flowcept.agents.agents_utils import ToolResult +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT_CHAT_MAX_QUERY_LIMIT +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.schemas.dashboards import Card, DashboardSpec +from flowcept.webservice.services import stats +from flowcept.webservice.services.dashboard_store import get_dashboard_store +from flowcept.webservice.services.serializers import normalize_docs + +ALLOWED_FILTER_OPERATORS = { + "$and", + "$or", + "$nor", + "$not", + "$exists", + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$in", + "$nin", + "$regex", +} + +MAX_QUERY_LIMIT = AGENT_CHAT_MAX_QUERY_LIMIT + + +def validate_filter(filter_doc: Optional[Dict[str, Any]]) -> None: + """Validate a Mongo-style filter against the safe-operator allowlist. + + Raises + ------ + ValueError + When the filter uses an operator outside the allowlist or has a bad shape. + """ + + def _walk(value: Any) -> None: + if isinstance(value, dict): + for key, item in value.items(): + if key.startswith("$"): + if key not in ALLOWED_FILTER_OPERATORS: + raise ValueError(f"Unsupported filter operator: {key}") + if key in {"$and", "$or", "$nor"} and not isinstance(item, list): + raise ValueError(f"{key} must be a list.") + _walk(item) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(filter_doc or {}) + + +def _guarded(tool_name: str): + """Decorator: validate filters, cap limits, and convert errors to ToolResult codes.""" + + def decorator(func): + def wrapper(*args, **kwargs): + try: + if "filter" in kwargs: + validate_filter(kwargs.get("filter")) + if "limit" in kwargs and kwargs["limit"]: + kwargs["limit"] = min(int(kwargs["limit"]), MAX_QUERY_LIMIT) + return func(*args, **kwargs) + except ValueError as e: + return ToolResult(code=400, result=str(e), tool_name=tool_name) + except Exception as e: + FlowceptLogger().exception(e) + return ToolResult(code=499, result=f"Error in {tool_name}: {e}", tool_name=tool_name) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + return decorator + + +def _normalize(docs: List[Dict]) -> List[Dict]: + return normalize_docs(docs) + + +@_guarded("query_tasks") +def query_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[List[str]] = None, + limit: int = 100, + sort: Optional[List[Dict[str, Any]]] = None, +) -> ToolResult: + """Query task provenance records with a Mongo-style filter. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter (e.g., ``{"workflow_id": "...", "status": "ERROR"}``). + projection : list of str, optional + Fields to include in results. + limit : int, optional + Maximum records (capped by settings). + sort : list of dict, optional + ``[{"field": "started_at", "order": -1}]``. + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + sort_tuples = None if not sort else [(s["field"], s["order"]) for s in sort] + docs = DBAPI().task_query(filter=filter or {}, projection=projection, limit=limit, sort=sort_tuples) or [] + items = _normalize(docs) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="query_tasks") + + +@_guarded("query_workflows") +def query_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> ToolResult: + """Query workflow provenance records with a Mongo-style filter. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter (e.g., ``{"campaign_id": "..."}``). + limit : int, optional + Maximum records (capped by settings). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + docs = (DBAPI().workflow_query(filter=filter or {}) or [])[:limit] + items = _normalize(docs) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="query_workflows") + + +@_guarded("get_task_summary") +def get_task_summary(filter: Optional[Dict[str, Any]] = None) -> ToolResult: + """Summarize tasks matching a filter: status counts, per-activity durations, time range. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter over tasks. + + Returns + ------- + ToolResult + ``result`` holds the summary dict. + """ + summary = stats.task_summary(DBAPI(), filter or {}) + return ToolResult(code=301, result=_normalize([summary])[0], tool_name="get_task_summary") + + +@_guarded("list_campaigns") +def list_campaigns() -> ToolResult: + """List derived campaign summaries (campaigns group workflows and tasks). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + items = _normalize(stats.derive_campaigns(DBAPI())) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="list_campaigns") + + +@_guarded("list_agents") +def list_agents() -> ToolResult: + """List derived agent summaries (agents observed in task provenance). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + items = _normalize(stats.derive_agents(DBAPI())) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="list_agents") + + +@_guarded("make_chart") +def make_chart(card_spec: Dict[str, Any], context: Optional[Dict[str, Any]] = None) -> ToolResult: + """Build a dashboard-style chart card: validate the spec and resolve its data rows. + + Parameters + ---------- + card_spec : dict + A dashboard ``Card`` spec (type chart/metric/table with a ``data`` binding). + context : dict, optional + Extra filter ANDed into the card data filter (e.g., ``{"workflow_id": "..."}``). + + Returns + ------- + ToolResult + ``result`` holds ``{"card": , "rows": [...], "count": int}``. + """ + card = Card(**card_spec) + if card.data is None: + return ToolResult(code=400, result="Card spec must include a data binding.", tool_name="make_chart") + validate_filter(card.data.filter) + if context: + validate_filter(context) + resolved = stats.resolve_card_data(DBAPI(), card.data, context=context) + result = {"card": card.model_dump(), "rows": _normalize(resolved["rows"]), "count": resolved["count"]} + return ToolResult(code=301, result=result, tool_name="make_chart") + + +@_guarded("get_dashboard") +def get_dashboard(dashboard_id: str) -> ToolResult: + """Get a stored dashboard spec by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + ToolResult + ``result`` holds the dashboard spec dict, or a 404 message. + """ + doc = get_dashboard_store().get(dashboard_id) + if doc is None: + return ToolResult(code=404, result=f"Dashboard not found: {dashboard_id}", tool_name="get_dashboard") + return ToolResult(code=301, result=doc, tool_name="get_dashboard") + + +@_guarded("update_dashboard") +def update_dashboard(dashboard_id: str, spec: Dict[str, Any]) -> ToolResult: + """Replace a stored dashboard spec (validated), preserving id and creation time. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + spec : dict + Full replacement ``DashboardSpec``. + + Returns + ------- + ToolResult + ``result`` holds the saved dashboard spec dict. + """ + store = get_dashboard_store() + existing = store.get(dashboard_id) + if existing is None: + return ToolResult(code=404, result=f"Dashboard not found: {dashboard_id}", tool_name="update_dashboard") + validated = DashboardSpec(**spec) + validate_filter(validated.context) + for card in validated.cards: + if card.data is not None: + validate_filter(card.data.filter) + validated.dashboard_id = dashboard_id + validated.created_at = existing.get("created_at") + validated.updated_at = datetime.now(timezone.utc).isoformat() + doc = validated.model_dump() + if not store.save(doc): + return ToolResult(code=500, result="Could not save dashboard.", tool_name="update_dashboard") + return ToolResult(code=301, result=doc, tool_name="update_dashboard") diff --git a/src/flowcept/cli.py b/src/flowcept/cli.py index f56e89e6..8b40300c 100644 --- a/src/flowcept/cli.py +++ b/src/flowcept/cli.py @@ -328,6 +328,27 @@ def start_agent(): # TODO: start with gui main() +def start_webservice(host: str = None, port: str = None): + """Start the Flowcept webservice (REST API + web UI). + + Parameters + ---------- + host : str, optional + Host to bind. Defaults to the web_server.host setting. + port : str, optional + Port to bind. Defaults to the web_server.port setting. + """ + import uvicorn + + from flowcept.configs import WEBSERVER_HOST, WEBSERVER_PORT + + uvicorn.run( + "flowcept.webservice.main:app", + host=host or WEBSERVER_HOST, + port=int(port) if port else WEBSERVER_PORT, + ) + + def start_agent_gui(port: int = None): """Start Flowcept agent GUI service. @@ -612,6 +633,7 @@ def start_redis() -> None: COMMAND_GROUPS = [ ("Basic Commands", [version, check_services, show_settings, init_settings, start_services, stop_services]), + ("Web Service Commands", [start_webservice]), ("Consumption Commands", [start_consumption_services, stop_consumption_services, stream_messages]), ("Database Commands", [workflow_count, query, get_task]), ("Agent Commands", [start_agent, agent_client, start_agent_gui]), @@ -684,6 +706,7 @@ def main(): # noqa: D103 description="Flowcept CLI", formatter_class=argparse.RawTextHelpFormatter, add_help=False ) + added_args = set() for func in COMMANDS: doc = func.__doc__ or "" func_name = func.__name__ @@ -693,6 +716,10 @@ def main(): # noqa: D103 for pname, param in inspect.signature(func).parameters.items(): arg_name = f"--{pname.replace('_', '-')}" + if arg_name in added_args: + # Parameter flags are global; commands may share names (e.g., --port). + continue + added_args.add(arg_name) params_doc = _parse_numpy_doc(doc).get(pname, {}) help_text = f"{params_doc.get('type', '')} - {params_doc.get('desc', '').strip()}" diff --git a/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py b/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py index 6584dd8d..4a575326 100644 --- a/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py +++ b/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py @@ -84,6 +84,7 @@ def __init__(self, create_indices=MONGO_CREATE_INDEX): self._wfs_collection = self._db["workflows"] self._obj_collection = self._db["objects"] self._obj_history_collection = self._db["object_history"] + self._dashboards_collection = self._db["dashboards"] if create_indices: self._create_indices() @@ -131,6 +132,11 @@ def _create_indices(self): if ["created_at"] not in existing_history_indices: self._obj_history_collection.create_index("created_at") + # Creating dashboards collection indices: + existing_indices = [list(x["key"].keys())[0] for x in self._dashboards_collection.list_indexes()] + if "dashboard_id" not in existing_indices: + self._dashboards_collection.create_index("dashboard_id", unique=True) + def _pipeline( self, filter: Dict = None, @@ -985,6 +991,117 @@ def raw_task_pipeline(self, pipeline: List[Dict]): self.logger.exception(e) return None + def raw_pipeline(self, pipeline: List[Dict], collection: str = "tasks"): + """ + Run a raw MongoDB aggregation pipeline on a chosen collection. + + Generalization of :meth:`raw_task_pipeline` for the other collections + (``workflows``, ``objects``, ``object_history``). + + Parameters + ---------- + pipeline : list of dict + A MongoDB aggregation pipeline represented as a list of stage documents. + collection : str, optional + Target collection name. Defaults to ``"tasks"``. + + Returns + ------- + list of dict or None + The aggregation results, or ``None`` if an error occurred. + """ + collections = { + "tasks": self._tasks_collection, + "workflows": self._wfs_collection, + "objects": self._obj_collection, + "object_history": self._obj_history_collection, + } + if collection not in collections: + raise ValueError(f"Unknown collection: {collection}. Expected one of {sorted(collections)}.") + try: + return list(collections[collection].aggregate(pipeline)) + except Exception as e: + self.logger.exception(e) + return None + + def save_dashboard(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard document keyed by ``dashboard_id``. + + Parameters + ---------- + dashboard : dict + Dashboard spec document containing a ``dashboard_id`` field. + + Returns + ------- + bool + True on success, False otherwise. + """ + try: + self._dashboards_collection.replace_one({"dashboard_id": dashboard["dashboard_id"]}, dashboard, upsert=True) + return True + except Exception as e: + self.logger.exception(e) + return False + + def get_dashboard(self, dashboard_id: str) -> Dict: + """Get a dashboard document by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + dict or None + The dashboard document, or None when not found. + """ + try: + return self._dashboards_collection.find_one({"dashboard_id": dashboard_id}, projection={"_id": 0}) + except Exception as e: + self.logger.exception(e) + return None + + def list_dashboards(self, filter: Dict = None) -> List[Dict]: + """List dashboard documents. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter. Defaults to all dashboards. + + Returns + ------- + list of dict + Matching dashboard documents. + """ + try: + return list(self._dashboards_collection.find(filter or {}, projection={"_id": 0})) + except Exception as e: + self.logger.exception(e) + return None + + def delete_dashboard(self, dashboard_id: str) -> bool: + """Delete a dashboard document by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + bool + True when a document was deleted, False otherwise. + """ + try: + result = self._dashboards_collection.delete_one({"dashboard_id": dashboard_id}) + return result.deleted_count > 0 + except Exception as e: + self.logger.exception(e) + return False + def task_query( self, filter: Dict = None, @@ -1044,7 +1161,12 @@ def task_query( _projection[proj_field] = 1 if remove_json_unserializables: - _projection.update({"_id": 0, "timestamp": 0}) + # Mongo only allows excluding `_id` inside an inclusion projection; excluding + # other fields (e.g., `timestamp`) is valid only in exclusion-only projections. + _projection.pop("timestamp", None) + _projection["_id"] = 0 + if projection is None: + _projection["timestamp"] = 0 try: rs = self._tasks_collection.find( filter=filter, diff --git a/src/flowcept/configs.py b/src/flowcept/configs.py index f134d7f8..0a010ab8 100644 --- a/src/flowcept/configs.py +++ b/src/flowcept/configs.py @@ -255,8 +255,15 @@ def _get_env_bool(name: str, default=False) -> bool: ###################### settings.setdefault("web_server", {}) _webserver_settings = settings.get("web_server", {}) -WEBSERVER_HOST = _webserver_settings.get("host", "0.0.0.0") -WEBSERVER_PORT = int(_webserver_settings.get("port", 5000)) +WEBSERVER_HOST = _get_env("WEBSERVER_HOST", _webserver_settings.get("host", "0.0.0.0")) +WEBSERVER_PORT = int(_get_env("WEBSERVER_PORT", _webserver_settings.get("port", 5000))) +WEBSERVER_UI_ENABLED = _webserver_settings.get("ui_enabled", True) +WEBSERVER_CORS_ORIGINS = _webserver_settings.get("cors_origins", []) +WEBSERVER_SSE_POLL_INTERVAL = float(_webserver_settings.get("sse_poll_interval_sec", 2.0)) +WEBSERVER_SSE_MAX_BATCH = int(_webserver_settings.get("sse_max_batch", 500)) +WEBSERVER_DASHBOARDS_DIR = os.path.expanduser( + _webserver_settings.get("dashboards_dir", f"~/.{PROJECT_NAME}/dashboards") +) ###################### # ANALYTICS # @@ -272,6 +279,9 @@ def _get_env_bool(name: str, default=False) -> bool: INSTRUMENTATION_ENABLED = INSTRUMENTATION.get("enabled", True) AGENT = settings.get("agent", {}) +AGENT_CHAT_ENABLED = AGENT.get("chat_enabled", True) +AGENT_CHAT_MAX_TOOL_ITERATIONS = int(AGENT.get("chat_max_tool_iterations", 5)) +AGENT_CHAT_MAX_QUERY_LIMIT = int(AGENT.get("chat_max_query_limit", 1000)) AGENT_AUDIO = _get_env_bool("AGENT_AUDIO", settings["agent"].get("audio_enabled", "false")) AGENT_HOST = _get_env("AGENT_HOST", settings["agent"].get("mcp_host", "localhost")) AGENT_PORT = int(_get_env("AGENT_PORT", settings["agent"].get("mcp_port", "8000"))) diff --git a/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py b/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py index 081f6936..7c8b1e82 100644 --- a/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py +++ b/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py @@ -116,7 +116,9 @@ async def lifespan(self, app): f.logger.info( f"This section's workflow_id={Flowcept.current_workflow_id}, campaign_id={Flowcept.campaign_id}" ) - self.start() + # Daemon consumer: the agent has no flush-on-stop obligations (persistence is off), + # and a blocked MQ listen must never prevent the hosting process from exiting. + self.start(daemon=True) try: yield self.context diff --git a/src/flowcept/report/service.py b/src/flowcept/report/service.py index 46f5cc3a..b567fec0 100644 --- a/src/flowcept/report/service.py +++ b/src/flowcept/report/service.py @@ -33,6 +33,62 @@ def _resolve_input_mode( return "db" +def build_provenance_card( + input_jsonl_path: str | None = None, + records: List[Dict[str, Any]] | None = None, + workflow_id: str | None = None, + campaign_id: str | None = None, +) -> Dict[str, Any]: + """Build the structured provenance-card content without rendering it. + + Accepts exactly one input mode (JSONL path, pre-loaded records, or DB query + by workflow/campaign id) and returns the aggregated structures consumed by + renderers and APIs. + + Parameters + ---------- + input_jsonl_path : str, optional + Path to a Flowcept JSONL buffer file. + records : list of dict, optional + Pre-loaded Flowcept records (workflow/task/object dicts). + workflow_id : str, optional + Workflow identifier for DB query mode. + campaign_id : str, optional + Campaign identifier for DB query mode. + + Returns + ------- + dict + ``{"dataset", "transformations", "object_summary", "input_mode", "skipped_lines"}``. + """ + mode = _resolve_input_mode( + input_jsonl_path=input_jsonl_path, + records=records, + workflow_id=workflow_id, + campaign_id=campaign_id, + ) + + skipped_lines = 0 + if mode == "jsonl": + jsonl_path = Path(input_jsonl_path) # type: ignore[arg-type] + if not jsonl_path.exists(): + raise FileNotFoundError(f"Input JSONL not found: {jsonl_path}") + parsed_records, skipped_lines = read_jsonl(jsonl_path) + dataset = split_records(parsed_records) + elif mode == "records": + dataset = split_records(records or []) + else: + dataset = load_records_from_db(workflow_id=workflow_id, campaign_id=campaign_id) + + return { + "dataset": dataset, + "transformations": group_transformations(dataset.get("tasks", [])), + "object_summary": summarize_objects(dataset.get("objects", [])), + "input_mode": mode, + "skipped_lines": skipped_lines, + } + + def generate_report( report_type: str = "provenance_card", format: str = "markdown", @@ -66,13 +122,6 @@ def generate_report( dict Report generation statistics and output path. """ - mode = _resolve_input_mode( - input_jsonl_path=input_jsonl_path, - records=records, - workflow_id=workflow_id, - campaign_id=campaign_id, - ) - if report_type != "provenance_card": raise ValueError(f"Unsupported report_type: {report_type}") if format != "markdown": @@ -82,31 +131,23 @@ def generate_report( output_path = "PROVENANCE_CARD.md" output = Path(output_path) - skipped_lines = 0 - if mode == "jsonl": - jsonl_path = Path(input_jsonl_path) # type: ignore[arg-type] - if not jsonl_path.exists(): - raise FileNotFoundError(f"Input JSONL not found: {jsonl_path}") - parsed_records, skipped_lines = read_jsonl(jsonl_path) - dataset = split_records(parsed_records) - elif mode == "records": - dataset = split_records(records or []) - else: - dataset = load_records_from_db(workflow_id=workflow_id, campaign_id=campaign_id) - - transformations = group_transformations(dataset.get("tasks", [])) - object_summary = summarize_objects(dataset.get("objects", [])) + card = build_provenance_card( + input_jsonl_path=input_jsonl_path, + records=records, + workflow_id=workflow_id, + campaign_id=campaign_id, + ) render_stats = render_provenance_card_markdown( - dataset=dataset, - transformations=transformations, - object_summary=object_summary, + dataset=card["dataset"], + transformations=card["transformations"], + object_summary=card["object_summary"], output_path=output, ) return { "report_type": report_type, "format": format, "output": str(output), - "input_mode": mode, - "skipped_lines": skipped_lines, + "input_mode": card["input_mode"], + "skipped_lines": card["skipped_lines"], **render_stats, } diff --git a/src/flowcept/webservice/README.md b/src/flowcept/webservice/README.md new file mode 100644 index 00000000..fd09d735 --- /dev/null +++ b/src/flowcept/webservice/README.md @@ -0,0 +1,229 @@ +# Flowcept Webservice + +This package contains the new read-only REST API layer for Flowcept. + +## Scope + +- Read-only endpoints for `workflows`, `tasks`, and `objects` +- Query endpoints for advanced filtering/projection/sort/aggregation +- OpenAPI-first delivery through FastAPI (`/openapi.json`, `/docs`, `/redoc`) +- No ingestion/write endpoints in v1 + +## Package Structure + +- `main.py`: FastAPI app factory and router registration +- `deps.py`: dependency injection helpers (currently `DBAPI`) +- `routers/`: endpoint modules by resource + - `health.py` + - `workflows.py` + - `tasks.py` + - `objects.py` +- `schemas/`: request/response payload models + - `common.py` +- `services/`: reusable service utilities + - `serializers.py` +- `docs/`: implementation and API contract docs for maintainers + +## Runtime behavior + +The webservice delegates all database reads to `flowcept.flowcept_api.db_api.DBAPI`. + +- Connection/backend selection stays centralized in existing Flowcept config (`flowcept.configs`) +- This package does not define a separate config system +- Host/port exposed in root response come from `WEBSERVER_HOST` and `WEBSERVER_PORT` + +## Base path + +All API routes are mounted under: + +- `/api/v1` + +## API summary + +### Health + +- `GET /api/v1/health/live` +- `GET /api/v1/health/ready` + +### Workflows + +- `GET /api/v1/workflows` +- `GET /api/v1/workflows/{workflow_id}` +- `POST /api/v1/workflows/query` +- `POST /api/v1/workflows/{workflow_id}/reports/provenance-card/download` + +### Tasks + +- `GET /api/v1/tasks` +- `GET /api/v1/tasks/{task_id}` +- `GET /api/v1/tasks/by_workflow/{workflow_id}` +- `POST /api/v1/tasks/query` + +### Objects + +- `GET /api/v1/objects` +- `GET /api/v1/objects/{object_id}` +- `GET /api/v1/objects/{object_id}/versions/{version}` +- `GET /api/v1/objects/{object_id}/download` +- `GET /api/v1/objects/{object_id}/versions/{version}/download` +- `GET /api/v1/objects/{object_id}/history` +- `POST /api/v1/objects/query` + +### Datasets + +- `GET /api/v1/datasets` +- `GET /api/v1/datasets/{object_id}` +- `GET /api/v1/datasets/{object_id}/versions/{version}` +- `GET /api/v1/datasets/{object_id}/download` +- `POST /api/v1/datasets/query` + +### Models + +- `GET /api/v1/models` +- `GET /api/v1/models/{object_id}` +- `GET /api/v1/models/{object_id}/versions/{version}` +- `GET /api/v1/models/{object_id}/download` +- `POST /api/v1/models/query` + +### Unified scoped query + +- `POST /api/v1/query/{scope}` +- Supported `scope`: `workflows | tasks | objects | models | datasets` +- `models` and `datasets` enforce fixed base filters (`type=ml_model` and `type=dataset`) + +### Campaigns (derived) + +- `GET /api/v1/campaigns` — campaign summaries derived by grouping workflows/tasks by `campaign_id` +- `GET /api/v1/campaigns/{campaign_id}` — summary + workflows + task summary +- `GET /api/v1/campaigns/{campaign_id}/provenance_card?format=json|markdown` + +### Agents (derived) + +- `GET /api/v1/agents` — agent summaries derived from task `agent_id`/`source_agent_id` +- `GET /api/v1/agents/{agent_id}` and `GET /api/v1/agents/{agent_id}/tasks` + +### Stats + +- `GET /api/v1/stats/tasks/summary?workflow_id=&campaign_id=&agent_id=&filter_json=` +- `POST /api/v1/stats/timeseries` — dot-notated (telemetry) fields over tasks for plotting +- `POST /api/v1/stats/card_data` — declarative dashboard card data resolver + +### Dashboards + +- `GET|POST /api/v1/dashboards`, `GET|PUT|DELETE /api/v1/dashboards/{dashboard_id}` +- Specs are validated (`schemas/dashboards.py`); stored in the `dashboards` Mongo collection, + or JSON files under `web_server.dashboards_dir` when MongoDB is disabled + +### Provenance cards + +- `GET /api/v1/workflows/{workflow_id}/provenance_card?format=json|markdown` + +### Live streams (SSE) + +- `GET /api/v1/stream/tasks?workflow_id=&campaign_id=&agent_id=&since=&poll_interval=` +- `GET /api/v1/stream/workflows?campaign_id=&since=&poll_interval=` +- Server-sent events backed by incremental DB polling; the `cursor` in each event resumes streams + +### Chat + +- `POST /api/v1/chat` — LLM chat with DB-backed provenance tools (shared with the MCP agent) +- Requires the `llm_agent` extra and the `agent` settings section; returns 503 otherwise +- `stream=true` (default) streams SSE events: `tool_call`, `tool_result`, `card`, `token`, `done` + +### Web UI + +- The built SPA (from `ui/`) is served at `/` when present (`make ui-build`); API always wins under `/api` + +## Query model + +Advanced `POST /query` endpoints share a common request model: + +```json +{ + "filter": {"workflow_id": "wf_123"}, + "projection": ["task_id", "started_at"], + "limit": 100, + "sort": [{"field": "started_at", "order": -1}], + "aggregation": [{"operator": "max", "field": "ended_at"}], + "remove_json_unserializables": true +} +``` + +### Semantics + +- `filter`: backend query filter document +- `projection`: list of field names to include +- `limit`: max number of docs returned (bounded in schema) +- `sort`: ordered field directions (`1` ascending, `-1` descending) +- `aggregation`: optional aggregate operations (`avg`, `sum`, `min`, `max`) +- `remove_json_unserializables`: pass-through behavior for DAO task/workflow queries + +`objects/query` also supports: + +- `include_data` (default `false`): include binary payload if available (base64 encoded) + +## Response shape + +List endpoints return: + +```json +{ + "items": [], + "count": 0, + "limit": 100 +} +``` + +Single-resource endpoints return one normalized document object. + +## Serialization rules + +`services/serializers.py` normalizes non-JSON-native values recursively: + +- `datetime` -> ISO8601 string +- `ObjectId` -> string +- `bytes` -> base64 string (only when payload inclusion is enabled) +- unknown objects -> string fallback + +For object metadata endpoints, `data` is excluded by default. + +## Error handling + +- Invalid JSON in `filter_json` -> `400` +- Missing resource -> `404` +- DAO validation/value errors -> `400` (or `404` where endpoint maps not-found) + +## Running locally + +```bash +uvicorn flowcept.webservice.main:app --host 0.0.0.0 --port 5000 +``` + +Swagger and OpenAPI are then available at: + +- `/docs` +- `/redoc` +- `/openapi.json` + +## OpenAPI artifact generation + +Generate static OpenAPI files for docs publishing: + +```bash +python docs/openapi/scripts/generate_openapi.py +``` + +Outputs: + +- `docs/openapi/flowcept-openapi.json` +- `docs/openapi/flowcept-openapi.yaml` + +## Extension roadmap + +When you add write endpoints later: + +1. Keep read/write routers split (`routers/read_*`, `routers/write_*`) or by resource with clear tags. +2. Introduce authN/authZ middleware before enabling writes. +3. Add explicit optimistic concurrency/version checks for object updates. +4. Add audit logging for mutating operations. +5. Add contract tests using FastAPI `TestClient`. diff --git a/src/flowcept/webservice/__init__.py b/src/flowcept/webservice/__init__.py new file mode 100644 index 00000000..961f9893 --- /dev/null +++ b/src/flowcept/webservice/__init__.py @@ -0,0 +1 @@ +"""Flowcept webservice package.""" diff --git a/src/flowcept/webservice/deps.py b/src/flowcept/webservice/deps.py new file mode 100644 index 00000000..37c6064c --- /dev/null +++ b/src/flowcept/webservice/deps.py @@ -0,0 +1,8 @@ +"""Dependency providers for Flowcept webservice.""" + +from flowcept.flowcept_api.db_api import DBAPI + + +def get_db_api() -> DBAPI: + """Return the shared DB API facade.""" + return DBAPI() diff --git a/src/flowcept/webservice/docs/API_CONTRACT.md b/src/flowcept/webservice/docs/API_CONTRACT.md new file mode 100644 index 00000000..2c1bac51 --- /dev/null +++ b/src/flowcept/webservice/docs/API_CONTRACT.md @@ -0,0 +1,189 @@ +# API Contract (v1) + +## Versioning + +- URL versioning: `/api/v1` +- Backward-incompatible changes require `/api/v2` + +## Resource model + +- `workflows`: workflow-level provenance records +- `tasks`: task-level provenance records +- `objects`: blob metadata and versioned object records + +## Default ordering + +List endpoints for workflows, tasks, and objects are ordered ascending by the first available date/timestamp field. + +## Endpoint details + +### GET /api/v1/workflows + +Query params: + +- `limit` (1..1000) +- `user` +- `campaign_id` +- `parent_workflow_id` +- `name` +- `filter_json` (JSON object encoded as string) + +### GET /api/v1/workflows/{workflow_id} + +Returns one workflow or `404`. + +### POST /api/v1/workflows/query + +Request body: shared query model. + +### POST /api/v1/workflows/{workflow_id}/reports/provenance-card/download + +Generates a provenance card markdown report for the workflow and downloads it as an attachment. + +### GET /api/v1/tasks + +Query params: + +- `limit` (1..1000) +- `workflow_id` +- `parent_task_id` +- `campaign_id` +- `task_id` +- `status` +- `filter_json` + +### GET /api/v1/tasks/{task_id} + +Returns one task or `404`. + +### GET /api/v1/tasks/by_workflow/{workflow_id} + +Returns tasks for a workflow. + +### POST /api/v1/tasks/query + +Supports `filter`, `projection`, `sort`, `limit`, `aggregation`. + +Validation rule: + +- if `aggregation` is provided, `projection` may include at most one field + +### GET /api/v1/objects + +Query params: + +- `limit` (1..1000) +- `object_id` +- `workflow_id` +- `task_id` +- `type` +- `filter_json` +- `include_data` (`false` by default) + +### GET /api/v1/objects/{object_id} + +Returns latest object metadata (plus data only when `include_data=true`). + +### GET /api/v1/objects/{object_id}/versions/{version} + +Returns specific object version or `404`. + +### GET /api/v1/objects/{object_id}/download + +Downloads latest object payload bytes as `application/octet-stream`. + +### GET /api/v1/objects/{object_id}/versions/{version}/download + +Downloads specific object version payload bytes as `application/octet-stream`. + +### GET /api/v1/objects/{object_id}/history + +Returns version history metadata sorted latest-first. + +### POST /api/v1/objects/query + +Same query model as above, plus `include_data`. + +### Datasets (`type=dataset`) + +- `GET /api/v1/datasets` +- `GET /api/v1/datasets/{object_id}` +- `GET /api/v1/datasets/{object_id}/versions/{version}` +- `GET /api/v1/datasets/{object_id}/download` +- `POST /api/v1/datasets/query` + +### Models (`type=ml_model`) + +- `GET /api/v1/models` +- `GET /api/v1/models/{object_id}` +- `GET /api/v1/models/{object_id}/versions/{version}` +- `GET /api/v1/models/{object_id}/download` +- `POST /api/v1/models/query` + +### POST /api/v1/query/{scope} + +Unified scoped read-only query endpoint. + +- `scope`: `workflows | tasks | objects | models | datasets` +- Uses the same query body model as other `/query` routes. +- `models` and `datasets` scopes enforce fixed type filters. +- Rejects unsupported filter operators. + +### Campaigns (derived; no campaigns collection) + +- `GET /api/v1/campaigns` — grouped from workflows/tasks by `campaign_id` +- `GET /api/v1/campaigns/{campaign_id}` — `{campaign, workflows, task_summary}`; 404 when nothing matches +- `GET /api/v1/campaigns/{campaign_id}/provenance_card?format=json|markdown` + +### Agents (derived from task `agent_id`/`source_agent_id`) + +- `GET /api/v1/agents` +- `GET /api/v1/agents/{agent_id}` — `{agent, task_summary}` +- `GET /api/v1/agents/{agent_id}/tasks` + +### Stats + +- `GET /api/v1/stats/tasks/summary` — `{count, status_counts, activity_stats, time_range}` +- `POST /api/v1/stats/timeseries` — body `{filter, fields: [dot-paths], x, limit}` → `{rows, count}` +- `POST /api/v1/stats/card_data` — body `{data: CardData, context}` → `{rows, count}`; + `CardData` is the declarative dashboard binding (`source, filter, group_by, metrics | x/y, sort, limit`) + +### Dashboards + +- `GET /api/v1/dashboards`, `POST /api/v1/dashboards` (201; server assigns `dashboard_id`) +- `GET|PUT|DELETE /api/v1/dashboards/{dashboard_id}` +- Specs validated against `schemas/dashboards.py::DashboardSpec`; card/context filters use the + same operator allowlist as `/query` + +### Provenance cards + +- `GET /api/v1/workflows/{workflow_id}/provenance_card?format=json|markdown` +- `format=json` returns `{dataset, transformations, object_summary, input_mode}` + +### Live streams (SSE) + +- `GET /api/v1/stream/tasks?workflow_id=&campaign_id=&agent_id=&since=&poll_interval=` +- `GET /api/v1/stream/workflows?campaign_id=&since=&poll_interval=` +- `text/event-stream`; events named `tasks`/`workflows` with data + `{tasks|workflows: [...], cursor: float, truncated: bool}`; pass `cursor` back as `since` to resume. + Backed by incremental DB polling (`web_server.sse_*` settings); no MQ coupling. + +### POST /api/v1/chat + +- Body: `{messages: [{role, content}], context, stream, allow_dashboard_edit}` (stateless; + client passes history) +- `stream=true`: SSE events `tool_call`, `tool_result`, `card`, `token`, `done`, `error` +- `stream=false`: one JSON `{message, tool_trace, cards}` +- LLM built from the `agent` settings section; `503` when not configured +- Tools are the shared provenance core (`flowcept.agents.tools.prov_tools`), also exposed via the + MCP agent as `query_provenance_tasks`, `list_provenance_campaigns`, etc. + +## Status codes + +- `200`: success +- `201`: dashboard created +- `400`: malformed input or unsupported query shape +- `404`: resource does not exist +- `422`: request schema validation error (FastAPI/Pydantic) +- `500`: unexpected internal error +- `503`: chat requested but no LLM configured/available diff --git a/src/flowcept/webservice/docs/ARCHITECTURE.md b/src/flowcept/webservice/docs/ARCHITECTURE.md new file mode 100644 index 00000000..2c48388b --- /dev/null +++ b/src/flowcept/webservice/docs/ARCHITECTURE.md @@ -0,0 +1,50 @@ +# Architecture Notes + +## Design goals + +1. Keep HTTP concerns isolated from storage/business code. +2. Reuse existing `DBAPI` to avoid duplicating datastore behavior. +3. Guarantee read-only behavior in v1. +4. Keep contract explicit via OpenAPI and typed schemas. + +## Layering + +- Router layer (`routers/*`) + - HTTP parsing and response shaping + - Path/query/body validation + - endpoint-level error mapping +- Dependency layer (`deps.py`) + - resolves `DBAPI` facade instance +- Service/util layer (`services/*`) + - reusable serialization and normalization helpers +- Data backend (`flowcept_api.DBAPI` -> DAO) + - all DB operations remain centralized in existing Flowcept stack + +## Why this package is separate + +`flowcept/flowcept_api` is a Python-facing API surface. `flowcept/webservice` is transport-facing (HTTP/OpenAPI). Keeping them separate prevents coupling transport details (status codes, query params, OpenAPI tags) into core provenance logic. + +## Configuration strategy + +No new config module is introduced in this package. + +- Existing config source: `flowcept.configs` +- Existing web server settings are reused (`WEBSERVER_HOST`, `WEBSERVER_PORT`) + +This avoids split-brain configuration and keeps deployment knobs centralized. + +## Read-only guarantees + +- Only `GET` and query `POST` routes exist. +- No write/upsert/delete routes in package. +- No raw Mongo pipeline execution endpoint is exposed in HTTP API v1. + +## Future evolution + +Potential additive changes without breaking v1: + +- pagination with cursor tokens +- auth middleware (API key/OIDC) +- request IDs and structured access logs +- response caching for expensive read queries +- metrics and tracing middleware diff --git a/src/flowcept/webservice/main.py b/src/flowcept/webservice/main.py new file mode 100644 index 00000000..470b9f9d --- /dev/null +++ b/src/flowcept/webservice/main.py @@ -0,0 +1,121 @@ +"""FastAPI entrypoint for Flowcept webservice.""" + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse + +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import ( + WEBSERVER_CORS_ORIGINS, + WEBSERVER_HOST, + WEBSERVER_PORT, + WEBSERVER_UI_ENABLED, +) +from flowcept.webservice.routers.agents import router as agents_router +from flowcept.webservice.routers.campaigns import router as campaigns_router +from flowcept.webservice.routers.dashboards import router as dashboards_router +from flowcept.webservice.routers.datasets import router as datasets_router +from flowcept.webservice.routers.health import router as health_router +from flowcept.webservice.routers.models import router as models_router +from flowcept.webservice.routers.objects import router as objects_router +from flowcept.webservice.routers.query import router as query_router +from flowcept.webservice.routers.stats import router as stats_router +from flowcept.webservice.routers.stream import router as stream_router +from flowcept.webservice.routers.tasks import router as tasks_router +from flowcept.webservice.routers.workflows import router as workflows_router + + +def create_app() -> FastAPI: + """Build and configure the FastAPI application.""" + app = FastAPI( + title="Flowcept Webservice API", + version="1.0.0", + description=( + "Read-only REST API for Flowcept provenance data. " + "Provides workflows, tasks, and objects endpoints with query support." + ), + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", + ) + + @app.exception_handler(ValueError) + async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse: + return JSONResponse(status_code=400, content={"detail": str(exc)}) + + if WEBSERVER_CORS_ORIGINS: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=WEBSERVER_CORS_ORIGINS, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(health_router, prefix="/api/v1") + app.include_router(campaigns_router, prefix="/api/v1") + app.include_router(workflows_router, prefix="/api/v1") + app.include_router(tasks_router, prefix="/api/v1") + app.include_router(objects_router, prefix="/api/v1") + app.include_router(datasets_router, prefix="/api/v1") + app.include_router(models_router, prefix="/api/v1") + app.include_router(agents_router, prefix="/api/v1") + app.include_router(stats_router, prefix="/api/v1") + app.include_router(dashboards_router, prefix="/api/v1") + app.include_router(stream_router, prefix="/api/v1") + try: + from flowcept.webservice.routers.chat import router as chat_router + + app.include_router(chat_router, prefix="/api/v1") + except Exception as e: + # Chat requires the llm_agent extra; the rest of the webservice works without it. + FlowceptLogger().warning(f"Chat endpoint not available: {e}") + app.include_router(query_router, prefix="/api/v1") + + _mount_ui(app) + + return app + + +def _mount_ui(app: FastAPI) -> None: + """Serve the built SPA when its assets are present; otherwise expose a status root.""" + ui_dir = Path(__file__).parent / "ui_build" + index_html = ui_dir / "index.html" + + if not (WEBSERVER_UI_ENABLED and index_html.exists()): + if WEBSERVER_UI_ENABLED: + FlowceptLogger().warning( + f"Web UI assets not found at {ui_dir}; serving API only. Build the UI with `make ui-build`." + ) + + @app.get("/", tags=["health"]) + def root() -> dict: + return { + "status": "up", + "service": "flowcept-webservice", + "host": WEBSERVER_HOST, + "port": WEBSERVER_PORT, + } + + return + + from fastapi.staticfiles import StaticFiles + + assets_dir = ui_dir / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + def spa_fallback(full_path: str): + reserved = ("api/", "docs", "redoc", "openapi.json", "assets/") + if full_path.startswith(reserved): + return JSONResponse(status_code=404, content={"detail": f"Not found: /{full_path}"}) + candidate = ui_dir / full_path + if full_path and candidate.is_file() and candidate.resolve().is_relative_to(ui_dir.resolve()): + return FileResponse(candidate) + return FileResponse(index_html) + + +app = create_app() diff --git a/src/flowcept/webservice/routers/__init__.py b/src/flowcept/webservice/routers/__init__.py new file mode 100644 index 00000000..11bedea7 --- /dev/null +++ b/src/flowcept/webservice/routers/__init__.py @@ -0,0 +1 @@ +"""Router modules for Flowcept webservice.""" diff --git a/src/flowcept/webservice/routers/agents.py b/src/flowcept/webservice/routers/agents.py new file mode 100644 index 00000000..652fe33e --- /dev/null +++ b/src/flowcept/webservice/routers/agents.py @@ -0,0 +1,62 @@ +"""Agent endpoints (derived from task ``agent_id``/``source_agent_id`` fields).""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.services import stats +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +@router.get("", response_model=ListResponse) +def list_agents( + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List derived agent summaries, most recently active first.""" + agents = stats.derive_agents(db)[:limit] + normalized = normalize_docs(agents) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{agent_id}", response_model=Dict[str, Any]) +def get_agent(agent_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get one agent's derived summary and per-activity task summary.""" + agents = [a for a in stats.derive_agents(db) if a["agent_id"] == agent_id] + if not agents: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + task_summary = stats.task_summary(db, {"agent_id": agent_id}) + return { + "agent": normalize_docs(agents)[0], + "task_summary": normalize_docs([task_summary])[0], + } + + +@router.get("/{agent_id}/tasks", response_model=ListResponse) +def get_agent_tasks( + agent_id: str, + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List tasks executed by or sent from an agent.""" + docs = ( + db.task_query( + filter={"$or": [{"agent_id": agent_id}, {"source_agent_id": agent_id}]}, + limit=limit, + ) + or [] + ) + docs = sort_docs_by_first_date_field( + docs, + ["started_at", "utc_timestamp", "ended_at", "registered_at"], + ) + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=limit) diff --git a/src/flowcept/webservice/routers/campaigns.py b/src/flowcept/webservice/routers/campaigns.py new file mode 100644 index 00000000..16b0b360 --- /dev/null +++ b/src/flowcept/webservice/routers/campaigns.py @@ -0,0 +1,66 @@ +"""Campaign endpoints (derived — campaigns exist as a grouping key, not a collection).""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query + +from fastapi.responses import Response + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.services import stats +from flowcept.webservice.services.reports import provenance_card_response +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("", response_model=ListResponse) +def list_campaigns( + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List derived campaign summaries, most recently active first.""" + campaigns = stats.derive_campaigns(db)[:limit] + normalized = normalize_docs(campaigns) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{campaign_id}", response_model=Dict[str, Any]) +def get_campaign(campaign_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get one campaign: derived summary, its workflows, and a task summary.""" + workflows = db.workflow_query(filter={"campaign_id": campaign_id}) or [] + task_summary = stats.task_summary(db, {"campaign_id": campaign_id}) + if not workflows and task_summary["count"] == 0: + raise HTTPException(status_code=404, detail=f"Campaign not found: {campaign_id}") + + workflows = sort_docs_by_first_date_field( + workflows, + ["utc_timestamp", "created_at", "updated_at", "timestamp", "started_at", "ended_at"], + ) + summary = next( + (c for c in stats.derive_campaigns(db) if c["campaign_id"] == campaign_id), + {"campaign_id": campaign_id}, + ) + return { + "campaign": normalize_docs([summary])[0], + "workflows": normalize_docs(workflows), + "task_summary": normalize_docs([task_summary])[0], + } + + +@router.get("/{campaign_id}/provenance_card") +def get_campaign_provenance_card( + campaign_id: str, + format: str = Query(default="json"), + db: DBAPI = Depends(get_db_api), +) -> Response: + """Get a campaign provenance card as structured JSON or rendered markdown.""" + workflows = db.workflow_query(filter={"campaign_id": campaign_id}) or [] + if not workflows: + raise HTTPException(status_code=404, detail=f"Campaign not found: {campaign_id}") + return provenance_card_response(format=format, campaign_id=campaign_id) diff --git a/src/flowcept/webservice/routers/chat.py b/src/flowcept/webservice/routers/chat.py new file mode 100644 index 00000000..7b824b8b --- /dev/null +++ b/src/flowcept/webservice/routers/chat.py @@ -0,0 +1,97 @@ +"""Chat endpoint: LLM with DB-backed provenance tools, streamed over SSE or as one JSON.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from sse_starlette.sse import EventSourceResponse + +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT, AGENT_CHAT_ENABLED +from flowcept.webservice.services.chat_service import run_chat + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +class ChatMessage(BaseModel): + """One conversation message.""" + + role: str + content: str + + +class ChatRequest(BaseModel): + """Chat request: client-passed history plus UI context.""" + + messages: List[ChatMessage] = Field(min_length=1) + context: Optional[Dict[str, Any]] = None + stream: bool = True + allow_dashboard_edit: bool = False + + +def get_chat_llm(): + """Build the chat LLM from the existing ``agent`` settings; 503 when unavailable.""" + if not AGENT_CHAT_ENABLED: + raise HTTPException(status_code=503, detail="Chat is disabled (agent.chat_enabled).") + api_key = AGENT.get("api_key") + if not api_key or api_key == "?": + raise HTTPException( + status_code=503, + detail="No LLM configured: set the agent section (api_key, service_provider, model) in settings.", + ) + try: + from flowcept.agents.agents_utils import build_llm_model + + return build_llm_model(track_tools=False) + except HTTPException: + raise + except Exception as e: + FlowceptLogger().exception(e) + raise HTTPException(status_code=503, detail=f"Could not build the chat LLM: {e}") from e + + +@router.post("") +def chat(payload: ChatRequest): + """Answer a provenance chat message, optionally streaming SSE events. + + Streaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, ``error``. + Non-streaming responses collect the same events into + ``{"message", "tool_trace", "cards"}``. + """ + llm = get_chat_llm() + messages = [m.model_dump() for m in payload.messages] + + events = run_chat( + llm, + messages=messages, + context=payload.context, + allow_dashboard_edit=payload.allow_dashboard_edit, + ) + + if payload.stream: + + def sse_events(): + for event in events: + yield {"event": event["event"], "data": json.dumps(event.get("data"), default=str)} + + return EventSourceResponse(sse_events(), ping=15) + + message_parts: List[str] = [] + tool_trace: List[Dict[str, Any]] = [] + cards: List[Dict[str, Any]] = [] + error: Optional[str] = None + for event in events: + if event["event"] == "token": + message_parts.append(str(event.get("data", ""))) + elif event["event"] in ("tool_call", "tool_result"): + tool_trace.append({"event": event["event"], **(event.get("data") or {})}) + elif event["event"] == "card": + cards.append(event.get("data") or {}) + elif event["event"] == "error": + error = str(event.get("data")) + if error and not message_parts: + raise HTTPException(status_code=500, detail=error) + return {"message": "".join(message_parts), "tool_trace": tool_trace, "cards": cards} diff --git a/src/flowcept/webservice/routers/dashboards.py b/src/flowcept/webservice/routers/dashboards.py new file mode 100644 index 00000000..e504628f --- /dev/null +++ b/src/flowcept/webservice/routers/dashboards.py @@ -0,0 +1,79 @@ +"""Dashboard CRUD endpoints for storing and serving dashboard JSON specs.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException + +from flowcept.webservice.routers.query import _validate_filter_shape +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.schemas.dashboards import DashboardSpec +from flowcept.webservice.services.dashboard_store import get_dashboard_store + +router = APIRouter(prefix="/dashboards", tags=["dashboards"]) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _validate_spec_filters(spec: DashboardSpec) -> None: + _validate_filter_shape(spec.context) + for card in spec.cards: + if card.data is not None: + _validate_filter_shape(card.data.filter) + + +@router.get("", response_model=ListResponse) +def list_dashboards(store=Depends(get_dashboard_store)) -> ListResponse: + """List all stored dashboards.""" + dashboards = store.list() + return ListResponse(items=dashboards, count=len(dashboards), limit=0) + + +@router.post("", response_model=Dict[str, Any], status_code=201) +def create_dashboard(spec: DashboardSpec, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Create a dashboard; the server assigns its id and timestamps.""" + _validate_spec_filters(spec) + spec.dashboard_id = str(uuid4()) + spec.created_at = spec.updated_at = _now() + doc = spec.model_dump() + if not store.save(doc): + raise HTTPException(status_code=500, detail="Could not save dashboard.") + return doc + + +@router.get("/{dashboard_id}", response_model=Dict[str, Any]) +def get_dashboard(dashboard_id: str, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Get a dashboard by id.""" + doc = store.get(dashboard_id) + if doc is None: + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + return doc + + +@router.put("/{dashboard_id}", response_model=Dict[str, Any]) +def update_dashboard(dashboard_id: str, spec: DashboardSpec, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Replace a dashboard spec, preserving its id and creation time.""" + existing = store.get(dashboard_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + _validate_spec_filters(spec) + spec.dashboard_id = dashboard_id + spec.created_at = existing.get("created_at") + spec.updated_at = _now() + doc = spec.model_dump() + if not store.save(doc): + raise HTTPException(status_code=500, detail="Could not save dashboard.") + return doc + + +@router.delete("/{dashboard_id}", response_model=Dict[str, Any]) +def delete_dashboard(dashboard_id: str, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Delete a dashboard by id.""" + if not store.delete(dashboard_id): + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + return {"deleted": dashboard_id} diff --git a/src/flowcept/webservice/routers/datasets.py b/src/flowcept/webservice/routers/datasets.py new file mode 100644 index 00000000..d4d7c8cf --- /dev/null +++ b/src/flowcept/webservice/routers/datasets.py @@ -0,0 +1,143 @@ +"""Dataset object endpoints (type=dataset).""" + +import json +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, ObjectQueryRequest +from flowcept.webservice.services.serializers import normalize_docs + +router = APIRouter(prefix="/datasets", tags=["datasets"]) + + +def _json_filter(filter_json: str | None) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +def _extract_binary_or_400(doc: Dict[str, Any]) -> bytes: + payload = doc.get("data") + if payload is None: + raise HTTPException(status_code=404, detail="Object payload not available.") + if isinstance(payload, bytes): + return payload + if isinstance(payload, str): + return payload.encode("utf-8") + raise HTTPException(status_code=400, detail=f"Unsupported payload type for download: {type(payload).__name__}") + + +@router.get("", response_model=ListResponse) +def list_datasets( + limit: int = Query(default=100, ge=1, le=1000), + workflow_id: str | None = None, + task_id: str | None = None, + object_id: str | None = None, + filter_json: str | None = None, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List dataset objects with optional filters.""" + query_filter = _json_filter(filter_json) + query_filter["type"] = "dataset" + if workflow_id is not None: + query_filter["workflow_id"] = workflow_id + if task_id is not None: + query_filter["task_id"] = task_id + if object_id is not None: + query_filter["object_id"] = object_id + + docs = (db.blob_object_query(filter=query_filter) or [])[:limit] + normalized = normalize_docs(docs, include_data=include_data) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{object_id}", response_model=Dict[str, Any]) +def get_dataset( + object_id: str, + version: int | None = None, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +): + """Get dataset object metadata by id and optional version.""" + try: + blob = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if blob is None or getattr(blob, "type", None) != "dataset": + raise HTTPException(status_code=404, detail=f"Dataset not found: {object_id}") + + return normalize_docs([blob.to_dict()], include_data=include_data)[0] + + +@router.get("/{object_id}/versions/{version}", response_model=Dict[str, Any]) +def get_dataset_version( + object_id: str, + version: int, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +): + """Get a specific dataset object version.""" + return get_dataset(object_id=object_id, version=version, include_data=include_data, db=db) + + +@router.get("/{object_id}/download") +def download_dataset( + object_id: str, + version: int | None = None, + db: DBAPI = Depends(get_db_api), +): + """Download dataset payload as a binary attachment.""" + try: + blob = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if blob is None or getattr(blob, "type", None) != "dataset": + raise HTTPException(status_code=404, detail=f"Dataset not found: {object_id}") + + doc = blob.to_dict() + payload = _extract_binary_or_400(doc) + filename = f"{object_id}.bin" if version is None else f"{object_id}.v{version}.bin" + return Response( + content=payload, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/query", response_model=ListResponse) +def query_datasets(payload: ObjectQueryRequest, db: DBAPI = Depends(get_db_api)): + """Run an advanced read-only query for dataset objects.""" + query_filter = dict(payload.filter) + query_filter["type"] = "dataset" + docs = db.query( + collection="objects", + filter=query_filter, + projection=payload.projection, + limit=payload.limit, + sort=None if payload.sort is None else [(s.field, s.order) for s in payload.sort], + aggregation=payload.aggregation, + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = docs or [] + if payload.projection: + docs = [{key: value for key, value in doc.items() if key in payload.projection} for doc in docs] + if payload.sort: + for sort_spec in reversed(payload.sort): + docs = sorted(docs, key=lambda item: item.get(sort_spec.field), reverse=(sort_spec.order == -1)) + docs = docs[: payload.limit] + + normalized = normalize_docs(docs, include_data=payload.include_data) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) diff --git a/src/flowcept/webservice/routers/health.py b/src/flowcept/webservice/routers/health.py new file mode 100644 index 00000000..2e95f38f --- /dev/null +++ b/src/flowcept/webservice/routers/health.py @@ -0,0 +1,17 @@ +"""Health endpoints.""" + +from fastapi import APIRouter + +router = APIRouter(prefix="/health", tags=["health"]) + + +@router.get("/live") +def live() -> dict: + """Liveness check.""" + return {"status": "ok"} + + +@router.get("/ready") +def ready() -> dict: + """Readiness check.""" + return {"status": "ready"} diff --git a/src/flowcept/webservice/routers/models.py b/src/flowcept/webservice/routers/models.py new file mode 100644 index 00000000..0bb3a954 --- /dev/null +++ b/src/flowcept/webservice/routers/models.py @@ -0,0 +1,143 @@ +"""ML model object endpoints (type=ml_model).""" + +import json +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, ObjectQueryRequest +from flowcept.webservice.services.serializers import normalize_docs + +router = APIRouter(prefix="/models", tags=["models"]) + + +def _json_filter(filter_json: str | None) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +def _extract_binary_or_400(doc: Dict[str, Any]) -> bytes: + payload = doc.get("data") + if payload is None: + raise HTTPException(status_code=404, detail="Object payload not available.") + if isinstance(payload, bytes): + return payload + if isinstance(payload, str): + return payload.encode("utf-8") + raise HTTPException(status_code=400, detail=f"Unsupported payload type for download: {type(payload).__name__}") + + +@router.get("", response_model=ListResponse) +def list_models( + limit: int = Query(default=100, ge=1, le=1000), + workflow_id: str | None = None, + task_id: str | None = None, + object_id: str | None = None, + filter_json: str | None = None, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List ML model objects with optional filters.""" + query_filter = _json_filter(filter_json) + query_filter["type"] = "ml_model" + if workflow_id is not None: + query_filter["workflow_id"] = workflow_id + if task_id is not None: + query_filter["task_id"] = task_id + if object_id is not None: + query_filter["object_id"] = object_id + + docs = (db.blob_object_query(filter=query_filter) or [])[:limit] + normalized = normalize_docs(docs, include_data=include_data) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{object_id}", response_model=Dict[str, Any]) +def get_model( + object_id: str, + version: int | None = None, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +): + """Get ML model object metadata by id and optional version.""" + try: + blob = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if blob is None or getattr(blob, "type", None) != "ml_model": + raise HTTPException(status_code=404, detail=f"Model not found: {object_id}") + + return normalize_docs([blob.to_dict()], include_data=include_data)[0] + + +@router.get("/{object_id}/versions/{version}", response_model=Dict[str, Any]) +def get_model_version( + object_id: str, + version: int, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +): + """Get a specific ML model object version.""" + return get_model(object_id=object_id, version=version, include_data=include_data, db=db) + + +@router.get("/{object_id}/download") +def download_model( + object_id: str, + version: int | None = None, + db: DBAPI = Depends(get_db_api), +): + """Download ML model payload as a binary attachment.""" + try: + blob = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if blob is None or getattr(blob, "type", None) != "ml_model": + raise HTTPException(status_code=404, detail=f"Model not found: {object_id}") + + doc = blob.to_dict() + payload = _extract_binary_or_400(doc) + filename = f"{object_id}.bin" if version is None else f"{object_id}.v{version}.bin" + return Response( + content=payload, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.post("/query", response_model=ListResponse) +def query_models(payload: ObjectQueryRequest, db: DBAPI = Depends(get_db_api)): + """Run an advanced read-only query for ML model objects.""" + query_filter = dict(payload.filter) + query_filter["type"] = "ml_model" + docs = db.query( + collection="objects", + filter=query_filter, + projection=payload.projection, + limit=payload.limit, + sort=None if payload.sort is None else [(s.field, s.order) for s in payload.sort], + aggregation=payload.aggregation, + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = docs or [] + if payload.projection: + docs = [{key: value for key, value in doc.items() if key in payload.projection} for doc in docs] + if payload.sort: + for sort_spec in reversed(payload.sort): + docs = sorted(docs, key=lambda item: item.get(sort_spec.field), reverse=(sort_spec.order == -1)) + docs = docs[: payload.limit] + + normalized = normalize_docs(docs, include_data=payload.include_data) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) diff --git a/src/flowcept/webservice/routers/objects.py b/src/flowcept/webservice/routers/objects.py new file mode 100644 index 00000000..30a84196 --- /dev/null +++ b/src/flowcept/webservice/routers/objects.py @@ -0,0 +1,183 @@ +"""Blob object endpoints.""" + +import json +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, ObjectQueryRequest +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/objects", tags=["objects"]) + + +def _json_filter(filter_json: str | None) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +def _extract_binary_or_400(doc: Dict[str, Any]) -> bytes: + payload = doc.get("data") + if payload is None: + raise HTTPException(status_code=404, detail="Object payload not available.") + if isinstance(payload, bytes): + return payload + if isinstance(payload, str): + return payload.encode("utf-8") + raise HTTPException(status_code=400, detail=f"Unsupported payload type for download: {type(payload).__name__}") + + +@router.get("", response_model=ListResponse) +def list_objects( + limit: int = Query(default=100, ge=1, le=1000), + object_id: str | None = None, + workflow_id: str | None = None, + task_id: str | None = None, + type: str | None = None, + filter_json: str | None = None, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List objects with optional basic filters.""" + query_filter = _json_filter(filter_json) + if object_id is not None: + query_filter["object_id"] = object_id + if workflow_id is not None: + query_filter["workflow_id"] = workflow_id + if task_id is not None: + query_filter["task_id"] = task_id + if type is not None: + query_filter["type"] = type + + docs = db.blob_object_query(filter=query_filter) or [] + docs = sort_docs_by_first_date_field(docs, ["created_at", "updated_at", "utc_timestamp", "timestamp"]) + docs = docs[:limit] + normalized = normalize_docs(docs, include_data=include_data) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{object_id}", response_model=Dict[str, Any]) +def get_object(object_id: str, include_data: bool = False, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get latest version of an object by id.""" + try: + obj = db.get_blob_object(object_id=object_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if obj is None: + raise HTTPException(status_code=404, detail=f"Object not found: {object_id}") + + normalized = normalize_docs([obj.to_dict()], include_data=include_data) + return normalized[0] + + +@router.get("/{object_id}/versions/{version}", response_model=Dict[str, Any]) +def get_object_version( + object_id: str, + version: int, + include_data: bool = False, + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Get a specific object version by id and version number.""" + try: + obj = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if obj is None: + raise HTTPException(status_code=404, detail=f"Object not found: {object_id}, version={version}") + + normalized = normalize_docs([obj.to_dict()], include_data=include_data) + return normalized[0] + + +@router.get("/{object_id}/download") +def download_object( + object_id: str, + version: int | None = None, + db: DBAPI = Depends(get_db_api), +) -> Response: + """Download object payload as binary.""" + try: + obj = db.get_blob_object(object_id=object_id, version=version) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + if obj is None: + raise HTTPException(status_code=404, detail=f"Object not found: {object_id}") + + payload = _extract_binary_or_400(obj.to_dict()) + filename = f"{object_id}.bin" if version is None else f"{object_id}.v{version}.bin" + return Response( + content=payload, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/{object_id}/versions/{version}/download") +def download_object_version( + object_id: str, + version: int, + db: DBAPI = Depends(get_db_api), +) -> Response: + """Download a specific object payload version as binary.""" + return download_object(object_id=object_id, version=version, db=db) + + +@router.get("/{object_id}/history", response_model=ListResponse) +def get_object_history( + object_id: str, + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """Get object metadata history (latest-first).""" + try: + history = db.get_object_history(object_id) or [] + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + history = history[:limit] + normalized = normalize_docs(history) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.post("/query", response_model=ListResponse) +def query_objects(payload: ObjectQueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: + """Run an advanced read-only object query.""" + docs = db.query( + collection="objects", + filter=payload.filter, + projection=payload.projection, + limit=payload.limit, + sort=None if payload.sort is None else [(s.field, s.order) for s in payload.sort], + aggregation=payload.aggregation, + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = docs or [] + + # Mongo object_query currently ignores projection/sort/limit. Apply API-level shaping here. + if payload.projection: + docs = [{key: value for key, value in doc.items() if key in payload.projection} for doc in docs] + if payload.sort: + for sort_spec in reversed(payload.sort): + docs = sorted( + docs, + key=lambda item: item.get(sort_spec.field), + reverse=(sort_spec.order == -1), + ) + docs = docs[: payload.limit] + + normalized = normalize_docs(docs, include_data=payload.include_data) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) diff --git a/src/flowcept/webservice/routers/query.py b/src/flowcept/webservice/routers/query.py new file mode 100644 index 00000000..b9f39548 --- /dev/null +++ b/src/flowcept/webservice/routers/query.py @@ -0,0 +1,128 @@ +"""Unified scoped query endpoint for read-only webservice access.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal + +from fastapi import APIRouter, Depends, HTTPException + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, ObjectQueryRequest +from flowcept.webservice.services.serializers import normalize_docs + +router = APIRouter(prefix="/query", tags=["query"]) + +QueryScope = Literal["workflows", "tasks", "objects", "models", "datasets"] +ALLOWED_OPERATORS = { + "$and", + "$or", + "$nor", + "$not", + "$exists", + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$in", + "$nin", + "$regex", +} + + +def _validate_filter_shape(filter_doc: Dict[str, Any]) -> None: + """Validate query filter shape and allowlist safe Mongo-like operators.""" + + def _walk(value: Any, *, in_operator_dict: bool = False) -> None: + if isinstance(value, dict): + for key, item in value.items(): + if key.startswith("$"): + if key not in ALLOWED_OPERATORS: + raise HTTPException(status_code=400, detail=f"Unsupported filter operator: {key}") + if key in {"$and", "$or", "$nor"}: + if not isinstance(item, list): + raise HTTPException(status_code=400, detail=f"{key} must be a list.") + for clause in item: + _walk(clause) + continue + if key == "$not": + if not isinstance(item, dict): + raise HTTPException(status_code=400, detail="$not must be an object.") + _walk(item, in_operator_dict=True) + continue + _walk(item, in_operator_dict=True) + else: + _walk(item, in_operator_dict=False) + elif isinstance(value, list): + for item in value: + _walk(item, in_operator_dict=in_operator_dict) + + _walk(filter_doc) + + +def _merge_base_filter(base_filter: Dict[str, Any], user_filter: Dict[str, Any]) -> Dict[str, Any]: + """Merge immutable base filter with user filter using conjunction.""" + if not base_filter: + return dict(user_filter) + if not user_filter: + return dict(base_filter) + return {"$and": [base_filter, user_filter]} + + +def _get_scope_metadata(scope: QueryScope) -> tuple[str, Dict[str, Any], bool]: + """Return `(collection, base_filter, include_data_supported)` for scope.""" + if scope == "workflows": + return "workflows", {}, False + if scope == "tasks": + return "tasks", {}, False + if scope == "objects": + return "objects", {}, True + if scope == "models": + return "objects", {"type": "ml_model"}, True + return "objects", {"type": "dataset"}, True + + +def _get_nested(item: Dict[str, Any], field: str) -> Any: + """Read dot-notated field value from a document.""" + current = item + for part in field.split("."): + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def _apply_shaping(docs: List[Dict[str, Any]], payload: ObjectQueryRequest) -> List[Dict[str, Any]]: + """Apply projection/sort/limit at API layer for backend consistency.""" + if payload.projection: + docs = [{key: value for key, value in doc.items() if key in payload.projection} for doc in docs] + if payload.sort: + for sort_spec in reversed(payload.sort): + docs = sorted( + docs, + key=lambda item: _get_nested(item, sort_spec.field), + reverse=(sort_spec.order == -1), + ) + return docs[: payload.limit] + + +@router.post("/{scope}", response_model=ListResponse) +def query_scope(scope: QueryScope, payload: ObjectQueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: + """Run a read-only advanced query over a constrained collection scope.""" + _validate_filter_shape(payload.filter) + collection, base_filter, include_data_supported = _get_scope_metadata(scope) + query_filter = _merge_base_filter(base_filter=base_filter, user_filter=payload.filter) + docs = db.query( + collection=collection, + filter=query_filter, + projection=payload.projection, + limit=payload.limit, + sort=None if payload.sort is None else [(s.field, s.order) for s in payload.sort], + aggregation=None if payload.aggregation is None else [(a.operator, a.field) for a in payload.aggregation], + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = _apply_shaping(docs=docs or [], payload=payload) + normalized = normalize_docs(docs, include_data=(payload.include_data and include_data_supported)) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) diff --git a/src/flowcept/webservice/routers/stats.py b/src/flowcept/webservice/routers/stats.py new file mode 100644 index 00000000..86dea69b --- /dev/null +++ b/src/flowcept/webservice/routers/stats.py @@ -0,0 +1,89 @@ +"""Stats endpoints: task summaries, telemetry timeseries, and the dashboard card-data resolver.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.routers.query import _validate_filter_shape +from flowcept.webservice.schemas.dashboards import CardData +from flowcept.webservice.services import stats +from flowcept.webservice.services.serializers import normalize_docs + +router = APIRouter(prefix="/stats", tags=["stats"]) + + +class TimeseriesRequest(BaseModel): + """Request body for telemetry/field timeseries extraction.""" + + filter: Dict[str, Any] = Field(default_factory=dict) + fields: List[str] + x: str = "started_at" + limit: int = Field(default=1000, ge=1, le=5000) + + +class CardDataRequest(BaseModel): + """Request body for the declarative card-data resolver.""" + + data: CardData + context: Optional[Dict[str, Any]] = None + + +def _json_filter(filter_json: Optional[str]) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +@router.get("/tasks/summary", response_model=Dict[str, Any]) +def get_task_summary( + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, + agent_id: Optional[str] = None, + filter_json: Optional[str] = None, + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Summarize tasks (status counts, per-activity durations, time range).""" + query_filter = _json_filter(filter_json) + for key, value in (("workflow_id", workflow_id), ("campaign_id", campaign_id), ("agent_id", agent_id)): + if value is not None: + query_filter[key] = value + _validate_filter_shape(query_filter) + return normalize_docs([stats.task_summary(db, query_filter)])[0] + + +@router.post("/timeseries", response_model=Dict[str, Any]) +def post_timeseries(payload: TimeseriesRequest, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Extract plottable rows of dot-notated fields from tasks.""" + _validate_filter_shape(payload.filter) + rows = stats.telemetry_timeseries( + db, + filter=payload.filter, + fields=payload.fields, + x_field=payload.x, + limit=payload.limit, + ) + rows = normalize_docs(rows) + return {"rows": rows, "count": len(rows)} + + +@router.post("/card_data", response_model=Dict[str, Any]) +def post_card_data(payload: CardDataRequest, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Resolve a declarative dashboard card data binding into rows.""" + _validate_filter_shape(payload.data.filter) + if payload.context: + _validate_filter_shape(payload.context) + result = stats.resolve_card_data(db, payload.data, context=payload.context) + result["rows"] = normalize_docs(result["rows"]) + return result diff --git a/src/flowcept/webservice/routers/stream.py b/src/flowcept/webservice/routers/stream.py new file mode 100644 index 00000000..5db9b8a7 --- /dev/null +++ b/src/flowcept/webservice/routers/stream.py @@ -0,0 +1,82 @@ +"""SSE endpoints streaming new/updated tasks and workflows via incremental DB polling.""" + +from __future__ import annotations + +import json +import time +from typing import Any, Dict, Optional + +import anyio +from fastapi import APIRouter, Depends, Query, Request +from sse_starlette.sse import EventSourceResponse + +from flowcept.configs import WEBSERVER_SSE_MAX_BATCH, WEBSERVER_SSE_POLL_INTERVAL +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.streaming import poll_new_docs + +router = APIRouter(prefix="/stream", tags=["stream"]) + + +def _event_stream( + request: Request, + db: DBAPI, + collection: str, + base_filter: Dict[str, Any], + since: float, + poll_interval: float, +): + """Async generator yielding one SSE event per poll that finds new documents.""" + payload_key = collection # "tasks" or "workflows" + + async def generator(): + cursor = since + while not await request.is_disconnected(): + docs, new_cursor, truncated = await anyio.to_thread.run_sync( + poll_new_docs, db, collection, base_filter, cursor, WEBSERVER_SSE_MAX_BATCH + ) + if docs: + cursor = new_cursor + yield { + "event": payload_key, + "data": json.dumps({payload_key: normalize_docs(docs), "cursor": cursor, "truncated": truncated}), + } + await anyio.sleep(poll_interval) + + return generator() + + +@router.get("/tasks") +def stream_tasks( + request: Request, + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, + agent_id: Optional[str] = None, + since: Optional[float] = None, + poll_interval: float = Query(default=WEBSERVER_SSE_POLL_INTERVAL, ge=0.1, le=60.0), + db: DBAPI = Depends(get_db_api), +) -> EventSourceResponse: + """Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent.""" + base_filter: Dict[str, Any] = {} + for key, value in (("workflow_id", workflow_id), ("campaign_id", campaign_id), ("agent_id", agent_id)): + if value is not None: + base_filter[key] = value + cursor = time.time() if since is None else since + return EventSourceResponse(_event_stream(request, db, "tasks", base_filter, cursor, poll_interval), ping=15) + + +@router.get("/workflows") +def stream_workflows( + request: Request, + campaign_id: Optional[str] = None, + since: Optional[float] = None, + poll_interval: float = Query(default=WEBSERVER_SSE_POLL_INTERVAL, ge=0.1, le=60.0), + db: DBAPI = Depends(get_db_api), +) -> EventSourceResponse: + """Stream new workflows as SSE events, optionally scoped by campaign.""" + base_filter: Dict[str, Any] = {} + if campaign_id is not None: + base_filter["campaign_id"] = campaign_id + cursor = time.time() if since is None else since + return EventSourceResponse(_event_stream(request, db, "workflows", base_filter, cursor, poll_interval), ping=15) diff --git a/src/flowcept/webservice/routers/tasks.py b/src/flowcept/webservice/routers/tasks.py new file mode 100644 index 00000000..c91cab80 --- /dev/null +++ b/src/flowcept/webservice/routers/tasks.py @@ -0,0 +1,114 @@ +"""Task endpoints.""" + +import json +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, QueryRequest +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +def _json_filter(filter_json: str | None) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +@router.get("", response_model=ListResponse) +def list_tasks( + limit: int = Query(default=100, ge=1, le=1000), + workflow_id: str | None = None, + parent_task_id: str | None = None, + campaign_id: str | None = None, + task_id: str | None = None, + status: str | None = None, + filter_json: str | None = None, + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List tasks with optional basic filters.""" + query_filter = _json_filter(filter_json) + if workflow_id is not None: + query_filter["workflow_id"] = workflow_id + if parent_task_id is not None: + query_filter["parent_task_id"] = parent_task_id + if campaign_id is not None: + query_filter["campaign_id"] = campaign_id + if task_id is not None: + query_filter["task_id"] = task_id + if status is not None: + query_filter["status"] = status + + docs = db.task_query(filter=query_filter, limit=0) or [] + docs = sort_docs_by_first_date_field( + docs, + ["started_at", "submitted_at", "registered_at", "ended_at", "utc_timestamp", "timestamp"], + ) + docs = docs[:limit] + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{task_id}", response_model=Dict[str, Any]) +def get_task(task_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get a task by id.""" + docs = db.task_query(filter={"task_id": task_id}, limit=1) or [] + if not docs: + raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") + normalized = normalize_docs([docs[0]]) + return normalized[0] + + +@router.get("/by_workflow/{workflow_id}", response_model=ListResponse) +def list_tasks_by_workflow( + workflow_id: str, + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List tasks for a workflow.""" + docs = db.task_query(filter={"workflow_id": workflow_id}, limit=0) or [] + docs = sort_docs_by_first_date_field( + docs, + ["started_at", "submitted_at", "registered_at", "ended_at", "utc_timestamp", "timestamp"], + ) + docs = docs[:limit] + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.post("/query", response_model=ListResponse) +def query_tasks(payload: QueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: + """Run an advanced read-only task query.""" + if payload.aggregation and payload.projection and len(payload.projection) > 1: + raise HTTPException( + status_code=400, + detail="When aggregation is provided, projection supports at most one field.", + ) + + sort = None if payload.sort is None else [(s.field, s.order) for s in payload.sort] + aggregation = None + if payload.aggregation is not None: + aggregation = [(agg.operator, agg.field) for agg in payload.aggregation] + + docs = db.task_query( + filter=payload.filter, + projection=payload.projection, + limit=payload.limit, + sort=sort, + aggregation=aggregation, + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = docs or [] + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) diff --git a/src/flowcept/webservice/routers/workflows.py b/src/flowcept/webservice/routers/workflows.py new file mode 100644 index 00000000..f5aa4771 --- /dev/null +++ b/src/flowcept/webservice/routers/workflows.py @@ -0,0 +1,139 @@ +"""Workflow endpoints.""" + +import json +import os +import tempfile +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from flowcept import Flowcept +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse, QueryRequest +from flowcept.webservice.services.reports import provenance_card_response +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/workflows", tags=["workflows"]) + + +def _json_filter(filter_json: str | None) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +@router.get("", response_model=ListResponse) +def list_workflows( + limit: int = Query(default=100, ge=1, le=1000), + user: str | None = None, + campaign_id: str | None = None, + parent_workflow_id: str | None = None, + name: str | None = None, + filter_json: str | None = None, + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List workflows with optional basic filters.""" + query_filter = _json_filter(filter_json) + if user is not None: + query_filter["user"] = user + if campaign_id is not None: + query_filter["campaign_id"] = campaign_id + if parent_workflow_id is not None: + query_filter["parent_workflow_id"] = parent_workflow_id + if name is not None: + query_filter["name"] = name + + docs = db.workflow_query(filter=query_filter) or [] + docs = sort_docs_by_first_date_field( + docs, + ["utc_timestamp", "created_at", "updated_at", "timestamp", "started_at", "ended_at"], + ) + docs = docs[:limit] + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{workflow_id}", response_model=Dict[str, Any]) +def get_workflow(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get a workflow by id.""" + doc = db.get_workflow_object(workflow_id) + if doc is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + normalized = normalize_docs([doc.to_dict()]) + return normalized[0] + + +@router.post("/query", response_model=ListResponse) +def query_workflows(payload: QueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: + """Run an advanced read-only workflows query.""" + sort = None if payload.sort is None else [(s.field, s.order) for s in payload.sort] + docs = db.query( + collection="workflows", + filter=payload.filter, + projection=payload.projection, + limit=payload.limit, + sort=sort, + aggregation=payload.aggregation, + remove_json_unserializables=payload.remove_json_unserializables, + ) + docs = docs or [] + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) + + +@router.get("/{workflow_id}/provenance_card") +def get_workflow_provenance_card( + workflow_id: str, + format: str = Query(default="json"), + db: DBAPI = Depends(get_db_api), +) -> Response: + """Get a workflow provenance card as structured JSON or rendered markdown.""" + wf_obj = db.get_workflow_object(workflow_id) + if wf_obj is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + return provenance_card_response(format=format, workflow_id=workflow_id) + + +@router.post("/{workflow_id}/reports/provenance-card/download") +def download_workflow_provenance_card(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> Response: + """Generate and download a workflow provenance card markdown file.""" + wf_obj = db.get_workflow_object(workflow_id) + if wf_obj is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + + fd, output_path = tempfile.mkstemp(prefix=f"provenance_card_{workflow_id}_", suffix=".md") + os.close(fd) + try: + Flowcept.generate_report( + report_type="provenance_card", + format="markdown", + output_path=output_path, + workflow_id=workflow_id, + ) + with open(output_path, "rb") as handle: + payload = handle.read() + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not generate provenance card: {exc}") from exc + finally: + try: + os.remove(output_path) + except Exception: + pass + + filename = f"provenance_card_{workflow_id}.md" + return Response( + content=payload, + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/src/flowcept/webservice/schemas/__init__.py b/src/flowcept/webservice/schemas/__init__.py new file mode 100644 index 00000000..2f552b78 --- /dev/null +++ b/src/flowcept/webservice/schemas/__init__.py @@ -0,0 +1 @@ +"""Schema modules for Flowcept webservice.""" diff --git a/src/flowcept/webservice/schemas/common.py b/src/flowcept/webservice/schemas/common.py new file mode 100644 index 00000000..25362700 --- /dev/null +++ b/src/flowcept/webservice/schemas/common.py @@ -0,0 +1,50 @@ +"""Shared request/response schemas for webservice endpoints.""" + +from typing import Any, Dict, List, Literal + +from pydantic import BaseModel, Field + + +class SortSpec(BaseModel): + """Sort field/order pair.""" + + field: str = Field(..., min_length=1) + order: Literal[1, -1] = 1 + + +class AggregationSpec(BaseModel): + """Aggregation operator and source field.""" + + operator: Literal["avg", "sum", "min", "max"] + field: str = Field(..., min_length=1) + + +class QueryRequest(BaseModel): + """Read-only query payload.""" + + filter: Dict[str, Any] = Field(default_factory=dict) + projection: List[str] | None = None + limit: int = Field(default=100, ge=0, le=1000) + sort: List[SortSpec] | None = None + aggregation: List[AggregationSpec] | None = None + remove_json_unserializables: bool = True + + +class ObjectQueryRequest(QueryRequest): + """Object query payload with optional payload inclusion.""" + + include_data: bool = False + + +class ListResponse(BaseModel): + """Generic list envelope for collection endpoints.""" + + items: List[Dict[str, Any]] + count: int + limit: int + + +class ErrorResponse(BaseModel): + """Error response envelope.""" + + detail: str diff --git a/src/flowcept/webservice/schemas/dashboards.py b/src/flowcept/webservice/schemas/dashboards.py new file mode 100644 index 00000000..0d7695e7 --- /dev/null +++ b/src/flowcept/webservice/schemas/dashboards.py @@ -0,0 +1,78 @@ +"""Pydantic schemas for dashboard specs and declarative card data bindings. + +The spec is deliberately declarative (not raw chart configs) so that LLM tools can +reliably generate/modify it and the frontend can validate and render it. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + +from flowcept.webservice.schemas.common import SortSpec + + +class MetricSpec(BaseModel): + """A single aggregation over a (dot-notated) field.""" + + field: str + agg: Literal["avg", "sum", "min", "max", "count"] + + +class CardData(BaseModel): + """Declarative data binding for a card: what to query and how to shape it.""" + + source: Literal["tasks", "workflows", "objects"] = "tasks" + filter: Dict[str, Any] = Field(default_factory=dict) + group_by: Optional[str] = None + metrics: Optional[List[MetricSpec]] = None + x: Optional[str] = None + y: Optional[List[str]] = None + sort: Optional[List[SortSpec]] = None + limit: int = Field(default=500, ge=1, le=5000) + + +class VizSpec(BaseModel): + """How a chart card renders its rows.""" + + kind: Literal["line", "bar", "pie", "scatter", "area", "heatmap"] = "line" + stacked: bool = False + + +class Card(BaseModel): + """One dashboard card. Content fields depend on ``type``.""" + + card_id: str + type: Literal["chart", "metric", "table", "markdown", "prov_card"] + title: str = "" + live: bool = False + refresh_interval_sec: Optional[float] = None + data: Optional[CardData] = None + viz: Optional[VizSpec] = None + content: Optional[str] = None + workflow_id: Optional[str] = None + campaign_id: Optional[str] = None + + +class LayoutItem(BaseModel): + """Grid placement of a card in a 12-column layout.""" + + card_id: str + x: int = Field(ge=0, le=11) + y: int = Field(ge=0) + w: int = Field(ge=1, le=12) + h: int = Field(ge=1) + + +class DashboardSpec(BaseModel): + """A complete dashboard: context filter, cards, and layout.""" + + dashboard_id: Optional[str] = None + name: str + description: str = "" + context: Dict[str, Any] = Field(default_factory=dict) + cards: List[Card] = Field(default_factory=list) + layout: List[LayoutItem] = Field(default_factory=list) + created_at: Optional[str] = None + updated_at: Optional[str] = None diff --git a/src/flowcept/webservice/services/__init__.py b/src/flowcept/webservice/services/__init__.py new file mode 100644 index 00000000..fd83c2a2 --- /dev/null +++ b/src/flowcept/webservice/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for Flowcept webservice.""" diff --git a/src/flowcept/webservice/services/chat_service.py b/src/flowcept/webservice/services/chat_service.py new file mode 100644 index 00000000..b5cfbeee --- /dev/null +++ b/src/flowcept/webservice/services/chat_service.py @@ -0,0 +1,166 @@ +"""LLM chat orchestration for the webservice: tool-calling loop over the shared prov tools.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Generator, List, Optional + +from flowcept.agents.prompts.chat_prompts import CHAT_SYSTEM_PROMPT +from flowcept.agents.tools import prov_tools +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT_CHAT_MAX_TOOL_ITERATIONS + +MAX_TOOL_ITERATIONS = AGENT_CHAT_MAX_TOOL_ITERATIONS + + +def _build_langchain_tools(context: Optional[Dict[str, Any]], allow_dashboard_edit: bool): + """Wrap the shared prov tool core as langchain tools (results JSON-encoded for the LLM).""" + from langchain_core.tools import tool + + def _run(func, **kwargs) -> str: + result = func(**kwargs) + payload = result.model_dump() if hasattr(result, "model_dump") else result + return json.dumps(payload, default=str) + + @tool + def query_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[List[str]] = None, + limit: int = 100, + sort: Optional[List[Dict[str, Any]]] = None, + ) -> str: + """Query task provenance records with a Mongo-style filter.""" + return _run(prov_tools.query_tasks, filter=filter, projection=projection, limit=limit, sort=sort) + + @tool + def query_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> str: + """Query workflow provenance records with a Mongo-style filter.""" + return _run(prov_tools.query_workflows, filter=filter, limit=limit) + + @tool + def get_task_summary(filter: Optional[Dict[str, Any]] = None) -> str: + """Summarize tasks: status counts, per-activity durations, and time range.""" + return _run(prov_tools.get_task_summary, filter=filter) + + @tool + def list_campaigns() -> str: + """List derived campaign summaries (campaigns group workflows and tasks).""" + return _run(prov_tools.list_campaigns) + + @tool + def list_agents() -> str: + """List derived agent summaries (agents observed in task provenance).""" + return _run(prov_tools.list_agents) + + @tool + def make_chart(card_spec: Dict[str, Any]) -> str: + """Build a chart from a declarative dashboard card spec; the UI renders the result.""" + return _run(prov_tools.make_chart, card_spec=card_spec, context=context) + + tools = [query_tasks, query_workflows, get_task_summary, list_campaigns, list_agents, make_chart] + + if allow_dashboard_edit: + + @tool + def get_dashboard(dashboard_id: str) -> str: + """Get a stored dashboard spec by id.""" + return _run(prov_tools.get_dashboard, dashboard_id=dashboard_id) + + @tool + def update_dashboard(dashboard_id: str, spec: Dict[str, Any]) -> str: + """Replace a stored dashboard spec with a complete revised spec.""" + return _run(prov_tools.update_dashboard, dashboard_id=dashboard_id, spec=spec) + + tools += [get_dashboard, update_dashboard] + return tools + + +def _build_messages(messages: List[Dict[str, str]], context: Optional[Dict[str, Any]]): + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + + system = CHAT_SYSTEM_PROMPT + if context: + system += f"\nCurrent user context (scope queries with it): {json.dumps(context)}" + lc_messages = [SystemMessage(content=system)] + for message in messages: + role = message.get("role") + content = message.get("content", "") + lc_messages.append(AIMessage(content=content) if role == "assistant" else HumanMessage(content=content)) + return lc_messages + + +def run_chat( + llm, + messages: List[Dict[str, str]], + context: Optional[Dict[str, Any]] = None, + allow_dashboard_edit: bool = False, +) -> Generator[Dict[str, Any], None, None]: + """Run one chat turn as a generator of events. + + Yields dict events: ``{"event": "tool_call"|"tool_result"|"card"|"token"|"done"|"error", ...}``. + The caller decides whether to stream them (SSE) or collect them into one response. + + Parameters + ---------- + llm : Any + A langchain chat model (from ``build_llm_model``). + messages : list of dict + Conversation history, ``[{"role": "user"|"assistant", "content": "..."}]``. + context : dict, optional + UI context (e.g., ``{"workflow_id": ...}``) injected into the system prompt and charts. + allow_dashboard_edit : bool, optional + Whether dashboard-modifying tools are bound. + """ + logger = FlowceptLogger() + tools = _build_langchain_tools(context, allow_dashboard_edit) + tools_by_name = {t.name: t for t in tools} + lc_messages = _build_messages(messages, context) + + try: + bound = llm.bind_tools(tools) + except (NotImplementedError, AttributeError): + logger.warning("Chat LLM does not support tool binding; answering without tools.") + bound = None + + try: + if bound is None: + response = llm.invoke(lc_messages) + yield {"event": "token", "data": getattr(response, "content", str(response))} + yield {"event": "done"} + return + + for _ in range(MAX_TOOL_ITERATIONS): + ai_message = bound.invoke(lc_messages) + tool_calls = getattr(ai_message, "tool_calls", None) or [] + if not tool_calls: + yield {"event": "token", "data": ai_message.content} + yield {"event": "done"} + return + + lc_messages.append(ai_message) + from langchain_core.messages import ToolMessage + + for call in tool_calls: + name = call["name"] + args = call.get("args") or {} + call_id = call.get("id") or name + yield {"event": "tool_call", "data": {"name": name, "args": args}} + tool_fn = tools_by_name.get(name) + output = tool_fn.invoke(args) if tool_fn is not None else json.dumps({"error": f"Unknown tool {name}"}) + lc_messages.append(ToolMessage(content=output, tool_call_id=call_id)) + + summary: Dict[str, Any] = {"name": name} + try: + parsed = json.loads(output) + summary["code"] = parsed.get("code") + if name == "make_chart" and isinstance(parsed.get("result"), dict): + yield {"event": "card", "data": parsed["result"]} + except Exception: + pass + yield {"event": "tool_result", "data": summary} + + yield {"event": "token", "data": "I reached the tool-call limit for this request. Please refine the question."} + yield {"event": "done"} + except Exception as e: + logger.exception(e) + yield {"event": "error", "data": str(e)} diff --git a/src/flowcept/webservice/services/dashboard_store.py b/src/flowcept/webservice/services/dashboard_store.py new file mode 100644 index 00000000..f5933c2c --- /dev/null +++ b/src/flowcept/webservice/services/dashboard_store.py @@ -0,0 +1,105 @@ +"""Dashboard persistence: MongoDB collection when available, JSON files otherwise.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Dict, List, Optional + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import WEBSERVER_DASHBOARDS_DIR + + +class MongoDashboardStore: + """Dashboard store backed by the ``dashboards`` MongoDB collection.""" + + def __init__(self, dao): + self._dao = dao + + def save(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard document.""" + return self._dao.save_dashboard(dashboard) + + def get(self, dashboard_id: str) -> Optional[Dict]: + """Get a dashboard document by id.""" + return self._dao.get_dashboard(dashboard_id) + + def list(self) -> List[Dict]: + """List all dashboard documents.""" + return self._dao.list_dashboards() or [] + + def delete(self, dashboard_id: str) -> bool: + """Delete a dashboard document by id.""" + return self._dao.delete_dashboard(dashboard_id) + + +class FileDashboardStore: + """Dashboard store writing one JSON file per dashboard under a local directory.""" + + def __init__(self, directory: str = WEBSERVER_DASHBOARDS_DIR): + self._dir = Path(directory) + self._dir.mkdir(parents=True, exist_ok=True) + self.logger = FlowceptLogger() + + def _path(self, dashboard_id: str) -> Path: + safe = "".join(c for c in dashboard_id if c.isalnum() or c in "-_") + return self._dir / f"{safe}.json" + + def save(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard JSON file.""" + try: + with open(self._path(dashboard["dashboard_id"]), "w") as handle: + json.dump(dashboard, handle, indent=2) + return True + except Exception as e: + self.logger.exception(e) + return False + + def get(self, dashboard_id: str) -> Optional[Dict]: + """Get a dashboard from its JSON file.""" + path = self._path(dashboard_id) + if not path.exists(): + return None + try: + with open(path) as handle: + return json.load(handle) + except Exception as e: + self.logger.exception(e) + return None + + def list(self) -> List[Dict]: + """List all dashboards stored as JSON files.""" + dashboards = [] + for path in sorted(self._dir.glob("*.json")): + try: + with open(path) as handle: + dashboards.append(json.load(handle)) + except Exception as e: + self.logger.exception(e) + return dashboards + + def delete(self, dashboard_id: str) -> bool: + """Delete a dashboard JSON file.""" + path = self._path(dashboard_id) + if not path.exists(): + return False + try: + os.remove(path) + return True + except Exception as e: + self.logger.exception(e) + return False + + +def get_dashboard_store(): + """Return the dashboard store for the configured DocDB backend. + + Mongo-backed deployments store dashboards in a ``dashboards`` collection; + other backends fall back to JSON files under ``web_server.dashboards_dir``. + """ + dao = DocumentDBDAO.get_instance(create_indices=False) + if hasattr(dao, "save_dashboard"): + return MongoDashboardStore(dao) + return FileDashboardStore() diff --git a/src/flowcept/webservice/services/reports.py b/src/flowcept/webservice/services/reports.py new file mode 100644 index 00000000..5bb5fb89 --- /dev/null +++ b/src/flowcept/webservice/services/reports.py @@ -0,0 +1,77 @@ +"""Provenance-card content helpers shared by workflow and campaign routers.""" + +from __future__ import annotations + +import os +import tempfile +from typing import Any, Dict, Optional + +from fastapi import HTTPException +from fastapi.responses import JSONResponse, Response + +from flowcept.report.service import build_provenance_card, generate_report +from flowcept.webservice.services.serializers import normalize_docs + + +def provenance_card_response( + format: str, + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, +) -> Response: + """Build a provenance card as a JSON or markdown HTTP response. + + Parameters + ---------- + format : str + ``"json"`` for the structured card content, ``"markdown"`` for the rendered card. + workflow_id : str, optional + Workflow scope (exactly one of workflow_id/campaign_id must be set). + campaign_id : str, optional + Campaign scope. + + Returns + ------- + Response + ``JSONResponse`` or markdown ``Response``. + """ + if format not in ("json", "markdown"): + raise HTTPException(status_code=400, detail=f"Unsupported format: {format}. Use json or markdown.") + + scope = workflow_id or campaign_id + try: + if format == "json": + card = build_provenance_card(workflow_id=workflow_id, campaign_id=campaign_id) + content: Dict[str, Any] = normalize_docs( + [ + { + "dataset": card["dataset"], + "transformations": card["transformations"], + "object_summary": card["object_summary"], + "input_mode": card["input_mode"], + } + ] + )[0] + return JSONResponse(content=content) + + fd, output_path = tempfile.mkstemp(prefix=f"provenance_card_{scope}_", suffix=".md") + os.close(fd) + try: + generate_report( + report_type="provenance_card", + format="markdown", + output_path=output_path, + workflow_id=workflow_id, + campaign_id=campaign_id, + ) + with open(output_path, "rb") as handle: + payload = handle.read() + finally: + try: + os.remove(output_path) + except Exception: + pass + return Response(content=payload, media_type="text/markdown; charset=utf-8") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not generate provenance card: {exc}") from exc diff --git a/src/flowcept/webservice/services/serializers.py b/src/flowcept/webservice/services/serializers.py new file mode 100644 index 00000000..70ccb2e1 --- /dev/null +++ b/src/flowcept/webservice/services/serializers.py @@ -0,0 +1,43 @@ +"""Serialization helpers for API responses.""" + +from __future__ import annotations + +import base64 +from datetime import datetime +from typing import Any, Dict, List + + +try: + from bson import ObjectId +except Exception: + ObjectId = None + + +def _to_jsonable(value: Any, include_data: bool = False) -> Any: + """Recursively normalize values for JSON responses.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, bytes): + if include_data: + return base64.b64encode(value).decode("ascii") + return None + if ObjectId is not None and isinstance(value, ObjectId): + return str(value) + if isinstance(value, list): + return [_to_jsonable(item, include_data=include_data) for item in value] + if isinstance(value, tuple): + return [_to_jsonable(item, include_data=include_data) for item in value] + if isinstance(value, dict): + return { + str(k): _to_jsonable(v, include_data=include_data) for k, v in value.items() if include_data or k != "data" + } + return str(value) + + +def normalize_docs(docs: List[Dict[str, Any]], include_data: bool = False) -> List[Dict[str, Any]]: + """Normalize result documents for JSON API response.""" + return [_to_jsonable(doc, include_data=include_data) for doc in docs] diff --git a/src/flowcept/webservice/services/sorting.py b/src/flowcept/webservice/services/sorting.py new file mode 100644 index 00000000..808c2d6c --- /dev/null +++ b/src/flowcept/webservice/services/sorting.py @@ -0,0 +1,48 @@ +"""Sorting helpers for webservice list endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List + + +def _as_sortable_number(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, datetime): + return value.timestamp() + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() + except Exception: + return None + return None + + +def sort_docs_by_first_date_field(docs: List[Dict[str, Any]], date_fields: List[str]) -> List[Dict[str, Any]]: + """Sort docs ascending by the first available date field from a priority list.""" + if len(docs) <= 1: + return docs + + chosen_field = None + for field in date_fields: + if any(_as_sortable_number(doc.get(field)) is not None for doc in docs): + chosen_field = field + break + + if chosen_field is None: + return docs + + return sorted( + docs, + key=lambda doc: ( + (0, _as_sortable_number(doc.get(chosen_field))) + if _as_sortable_number(doc.get(chosen_field)) is not None + else (1, float("inf")) + ), + ) diff --git a/src/flowcept/webservice/services/stats.py b/src/flowcept/webservice/services/stats.py new file mode 100644 index 00000000..f83c99a7 --- /dev/null +++ b/src/flowcept/webservice/services/stats.py @@ -0,0 +1,569 @@ +"""Aggregation and derivation services for webservice stats endpoints and dashboard cards. + +Mongo-backed deployments use native aggregation pipelines; other backends (e.g., LMDB) +fall back to in-Python aggregation over plain queries. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.schemas.dashboards import CardData, MetricSpec + + +def _mongo_dao_or_none() -> Optional[DocumentDBDAO]: + """Return the DAO singleton when it supports raw aggregation pipelines, else None.""" + dao = DocumentDBDAO.get_instance(create_indices=False) + return dao if hasattr(dao, "raw_pipeline") else None + + +def get_nested(item: Dict[str, Any], field: str) -> Any: + """Read a dot-notated field value from a document.""" + current = item + for part in field.split("."): + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def _duration(doc: Dict[str, Any]) -> Optional[float]: + started, ended = doc.get("started_at"), doc.get("ended_at") + if isinstance(started, (int, float)) and isinstance(ended, (int, float)): + return ended - started + return None + + +def task_summary(db: DBAPI, filter: Dict[str, Any]) -> Dict[str, Any]: + """Summarize tasks matching a filter: status counts, per-activity stats, and time range. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict + Mongo-style filter over the ``tasks`` collection. + + Returns + ------- + dict + ``{"count", "status_counts", "activity_stats", "time_range"}``. + """ + dao = _mongo_dao_or_none() + if dao is not None: + return _task_summary_mongo(dao, filter) + return _task_summary_python(db, filter) + + +def _task_summary_mongo(dao, filter: Dict[str, Any]) -> Dict[str, Any]: + match = [{"$match": filter}] if filter else [] + rows = ( + dao.raw_pipeline( + match + + [ + { + "$group": { + "_id": {"activity_id": "$activity_id", "status": "$status"}, + "count": {"$sum": 1}, + "avg_duration": {"$avg": {"$subtract": ["$ended_at", "$started_at"]}}, + "min_duration": {"$min": {"$subtract": ["$ended_at", "$started_at"]}}, + "max_duration": {"$max": {"$subtract": ["$ended_at", "$started_at"]}}, + "sum_duration": {"$sum": {"$subtract": ["$ended_at", "$started_at"]}}, + "min_started_at": {"$min": "$started_at"}, + "max_ended_at": {"$max": "$ended_at"}, + } + } + ], + collection="tasks", + ) + or [] + ) + return _merge_summary_rows( + [ + { + "activity_id": row["_id"].get("activity_id"), + "status": row["_id"].get("status"), + **{k: row.get(k) for k in row if k != "_id"}, + } + for row in rows + ] + ) + + +def _task_summary_python(db: DBAPI, filter: Dict[str, Any]) -> Dict[str, Any]: + docs = ( + db.task_query( + filter=filter, + projection=["activity_id", "status", "started_at", "ended_at"], + ) + or [] + ) + groups: Dict[tuple, Dict[str, Any]] = {} + for doc in docs: + key = (doc.get("activity_id"), doc.get("status")) + group = groups.setdefault( + key, + { + "activity_id": key[0], + "status": key[1], + "count": 0, + "durations": [], + "min_started_at": None, + "max_ended_at": None, + }, + ) + group["count"] += 1 + duration = _duration(doc) + if duration is not None: + group["durations"].append(duration) + started, ended = doc.get("started_at"), doc.get("ended_at") + if isinstance(started, (int, float)): + current = group["min_started_at"] + group["min_started_at"] = started if current is None else min(current, started) + if isinstance(ended, (int, float)): + current = group["max_ended_at"] + group["max_ended_at"] = ended if current is None else max(current, ended) + + rows = [] + for group in groups.values(): + durations = group.pop("durations") + group["avg_duration"] = sum(durations) / len(durations) if durations else None + group["min_duration"] = min(durations) if durations else None + group["max_duration"] = max(durations) if durations else None + group["sum_duration"] = sum(durations) if durations else None + rows.append(group) + return _merge_summary_rows(rows) + + +def _merge_summary_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: + """Combine per-(activity,status) rows into the summary response shape.""" + status_counts: Dict[str, int] = defaultdict(int) + activities: Dict[str, Dict[str, Any]] = {} + total = 0 + min_started, max_ended = None, None + for row in rows: + count = row.get("count") or 0 + total += count + status = row.get("status") or "UNKNOWN" + status_counts[status] += count + + activity = activities.setdefault( + str(row.get("activity_id")), + { + "activity_id": row.get("activity_id"), + "count": 0, + "status_counts": defaultdict(int), + "avg_duration": None, + "min_duration": None, + "max_duration": None, + "sum_duration": None, + "_weighted_sum": 0.0, + "_weighted_count": 0, + }, + ) + activity["count"] += count + activity["status_counts"][status] += count + for bound, op in (("min_duration", min), ("max_duration", max)): + if row.get(bound) is not None: + current = activity[bound] + activity[bound] = row[bound] if current is None else op(current, row[bound]) + if row.get("sum_duration") is not None: + activity["sum_duration"] = (activity["sum_duration"] or 0) + row["sum_duration"] + if row.get("avg_duration") is not None: + activity["_weighted_sum"] += row["avg_duration"] * count + activity["_weighted_count"] += count + + if row.get("min_started_at") is not None: + min_started = row["min_started_at"] if min_started is None else min(min_started, row["min_started_at"]) + if row.get("max_ended_at") is not None: + max_ended = row["max_ended_at"] if max_ended is None else max(max_ended, row["max_ended_at"]) + + activity_stats = [] + for activity in activities.values(): + if activity.pop("_weighted_count"): + activity["avg_duration"] = activity.pop("_weighted_sum") / activity["count"] + else: + activity.pop("_weighted_sum") + activity["status_counts"] = dict(activity["status_counts"]) + activity_stats.append(activity) + activity_stats.sort(key=lambda a: str(a["activity_id"])) + + return { + "count": total, + "status_counts": dict(status_counts), + "activity_stats": activity_stats, + "time_range": {"min_started_at": min_started, "max_ended_at": max_ended}, + } + + +def derive_campaigns(db: DBAPI) -> List[Dict[str, Any]]: + """Derive campaign summaries by grouping workflows and tasks by ``campaign_id``. + + There is no campaigns collection; campaigns exist as a grouping key. + + Returns + ------- + list of dict + One record per campaign with workflow/task counts, users, names, and time range. + """ + campaigns: Dict[str, Dict[str, Any]] = {} + + def _campaign(campaign_id: str) -> Dict[str, Any]: + return campaigns.setdefault( + campaign_id, + { + "campaign_id": campaign_id, + "workflow_count": 0, + "task_count": 0, + "users": set(), + "workflow_names": set(), + "first_ts": None, + "last_ts": None, + }, + ) + + def _expand_range(record: Dict[str, Any], *values) -> None: + for value in values: + if not isinstance(value, (int, float)): + continue + record["first_ts"] = value if record["first_ts"] is None else min(record["first_ts"], value) + record["last_ts"] = value if record["last_ts"] is None else max(record["last_ts"], value) + + dao = _mongo_dao_or_none() + if dao is not None: + wf_rows = ( + dao.raw_pipeline( + [ + {"$match": {"campaign_id": {"$exists": True, "$ne": None}}}, + { + "$group": { + "_id": "$campaign_id", + "workflow_count": {"$sum": 1}, + "users": {"$addToSet": "$user"}, + "workflow_names": {"$addToSet": "$name"}, + "first_ts": {"$min": "$utc_timestamp"}, + "last_ts": {"$max": "$utc_timestamp"}, + } + }, + ], + collection="workflows", + ) + or [] + ) + task_rows = ( + dao.raw_pipeline( + [ + {"$match": {"campaign_id": {"$exists": True, "$ne": None}}}, + { + "$group": { + "_id": "$campaign_id", + "task_count": {"$sum": 1}, + "first_ts": {"$min": "$started_at"}, + "last_ts": {"$max": "$ended_at"}, + } + }, + ], + collection="tasks", + ) + or [] + ) + for row in wf_rows: + record = _campaign(row["_id"]) + record["workflow_count"] = row.get("workflow_count", 0) + record["users"].update(u for u in row.get("users", []) if u) + record["workflow_names"].update(n for n in row.get("workflow_names", []) if n) + _expand_range(record, row.get("first_ts"), row.get("last_ts")) + for row in task_rows: + record = _campaign(row["_id"]) + record["task_count"] = row.get("task_count", 0) + _expand_range(record, row.get("first_ts"), row.get("last_ts")) + else: + wf_filter = {"campaign_id": {"$exists": True, "$ne": None}} + for doc in db.workflow_query(filter=wf_filter) or []: + if not doc.get("campaign_id"): + continue + record = _campaign(doc["campaign_id"]) + record["workflow_count"] += 1 + if doc.get("user"): + record["users"].add(doc["user"]) + if doc.get("name"): + record["workflow_names"].add(doc["name"]) + _expand_range(record, doc.get("utc_timestamp")) + task_docs = ( + db.task_query( + filter=wf_filter, + projection=["campaign_id", "started_at", "ended_at"], + ) + or [] + ) + for doc in task_docs: + if not doc.get("campaign_id"): + continue + record = _campaign(doc["campaign_id"]) + record["task_count"] += 1 + _expand_range(record, doc.get("started_at"), doc.get("ended_at")) + + results = [] + for record in campaigns.values(): + record["users"] = sorted(record["users"]) + record["workflow_names"] = sorted(record["workflow_names"]) + results.append(record) + results.sort(key=lambda r: (r["last_ts"] is None, r["last_ts"]), reverse=True) + return results + + +def derive_agents(db: DBAPI, filter: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """Derive agent summaries by grouping tasks by ``agent_id``. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict, optional + Extra Mongo-style filter ANDed with the agent-existence condition. + + Returns + ------- + list of dict + One record per agent with task counts, activities, and last activity time. + """ + base = {"agent_id": {"$exists": True, "$ne": None}} + query_filter = {"$and": [base, filter]} if filter else base + + dao = _mongo_dao_or_none() + if dao is not None: + rows = ( + dao.raw_pipeline( + [ + {"$match": query_filter}, + { + "$group": { + "_id": "$agent_id", + "task_count": {"$sum": 1}, + "activities": {"$addToSet": "$activity_id"}, + "source_agent_ids": {"$addToSet": "$source_agent_id"}, + "campaign_ids": {"$addToSet": "$campaign_id"}, + "last_active": {"$max": "$utc_timestamp"}, + } + }, + ], + collection="tasks", + ) + or [] + ) + agents = [ + { + "agent_id": row["_id"], + "task_count": row.get("task_count", 0), + "activities": sorted(a for a in row.get("activities", []) if a), + "source_agent_ids": sorted(s for s in row.get("source_agent_ids", []) if s), + "campaign_ids": sorted(c for c in row.get("campaign_ids", []) if c), + "last_active": row.get("last_active"), + } + for row in rows + ] + else: + docs = ( + db.task_query( + filter=query_filter, + projection=["agent_id", "activity_id", "source_agent_id", "campaign_id", "utc_timestamp"], + ) + or [] + ) + grouped: Dict[str, Dict[str, Any]] = {} + for doc in docs: + agent_id = doc.get("agent_id") + if not agent_id: + continue + record = grouped.setdefault( + agent_id, + { + "agent_id": agent_id, + "task_count": 0, + "activities": set(), + "source_agent_ids": set(), + "campaign_ids": set(), + "last_active": None, + }, + ) + record["task_count"] += 1 + for key, field in ( + ("activities", "activity_id"), + ("source_agent_ids", "source_agent_id"), + ("campaign_ids", "campaign_id"), + ): + if doc.get(field): + record[key].add(doc[field]) + ts = doc.get("utc_timestamp") + if isinstance(ts, (int, float)): + current = record["last_active"] + record["last_active"] = ts if current is None else max(current, ts) + agents = [ + {**record, **{key: sorted(record[key]) for key in ("activities", "source_agent_ids", "campaign_ids")}} + for record in grouped.values() + ] + + agents.sort(key=lambda a: (a["last_active"] is None, a["last_active"]), reverse=True) + return agents + + +def telemetry_timeseries( + db: DBAPI, + filter: Dict[str, Any], + fields: List[str], + x_field: str = "started_at", + limit: int = 1000, +) -> List[Dict[str, Any]]: + """Extract plottable rows of dot-notated (telemetry) fields from tasks. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict + Mongo-style filter over tasks. + fields : list of str + Dot-notated y-value paths (e.g., ``telemetry_at_end.cpu.percent_all``). + x_field : str, optional + X-axis field (a task time field by default). + limit : int, optional + Maximum number of rows. + + Returns + ------- + list of dict + Rows of ``{x_field, task_id, activity_id, : value, ...}`` sorted by x. + """ + top_level = sorted({field.split(".")[0] for field in fields} | {x_field.split(".")[0]}) + docs = ( + db.task_query( + filter=filter, + projection=["task_id", "activity_id"] + top_level, + limit=limit, + ) + or [] + ) + rows = [] + for doc in docs: + row = { + x_field: get_nested(doc, x_field), + "task_id": doc.get("task_id"), + "activity_id": doc.get("activity_id"), + } + row.update({field: get_nested(doc, field) for field in fields}) + rows.append(row) + rows.sort(key=lambda r: (r[x_field] is None, r[x_field])) + return rows + + +def _merge_context_filter(card_filter: Dict[str, Any], context: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not context: + return dict(card_filter) + if not card_filter: + return dict(context) + return {"$and": [context, card_filter]} + + +def resolve_card_data(db: DBAPI, data: "CardData", context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Resolve a declarative card data binding into plottable rows. + + This is the single data contract shared by dashboard cards, the stats router, + and LLM chart tools. + + Parameters + ---------- + db : DBAPI + DB API facade. + data : CardData + Declarative binding (source, filter, group_by/metrics or x/y, sort, limit). + context : dict, optional + Dashboard-level filter ANDed into the card filter (e.g., ``{"campaign_id": ...}``). + + Returns + ------- + dict + ``{"rows": [...], "count": int}``. + """ + query_filter = _merge_context_filter(data.filter, context) + + if data.group_by or data.metrics: + rows = _resolve_grouped(db, data, query_filter) + elif data.x and data.y: + rows = telemetry_timeseries(db, query_filter, fields=data.y, x_field=data.x, limit=data.limit) + else: + sort = None if data.sort is None else [(s.field, s.order) for s in data.sort] + rows = ( + db.query( + collection=data.source, + filter=query_filter, + limit=data.limit, + sort=sort, + ) + or [] + ) + rows = rows[: data.limit] + return {"rows": rows, "count": len(rows)} + + +def _resolve_grouped(db: DBAPI, data: "CardData", query_filter: Dict[str, Any]) -> List[Dict[str, Any]]: + """Group/aggregate rows for a card, using Mongo pipelines when available.""" + metrics = data.metrics or [MetricSpec(field="", agg="count")] + dao = _mongo_dao_or_none() + if dao is not None and data.source in ("tasks", "workflows", "objects"): + group_id = f"${data.group_by}" if data.group_by else None + group_stage: Dict[str, Any] = {"_id": group_id} + for metric in metrics: + if metric.agg == "count": + group_stage[_metric_key(metric)] = {"$sum": 1} + else: + group_stage[_metric_key(metric)] = {f"${metric.agg}": f"${metric.field}"} + pipeline = ([{"$match": query_filter}] if query_filter else []) + [{"$group": group_stage}] + rows = dao.raw_pipeline(pipeline, collection=data.source) or [] + out = [] + for row in rows: + record = {data.group_by or "group": row.pop("_id")} + record.update(row) + out.append(record) + out.sort(key=lambda r: str(r.get(data.group_by or "group"))) + return out + + fields = sorted({m.field for m in metrics if m.field}) + top_level = sorted({f.split(".")[0] for f in fields} | ({data.group_by.split(".")[0]} if data.group_by else set())) + docs = ( + db.query( + collection=data.source, + filter=query_filter, + projection=top_level or None, + ) + or [] + ) + grouped: Dict[Any, List[Dict[str, Any]]] = defaultdict(list) + for doc in docs: + grouped[get_nested(doc, data.group_by) if data.group_by else None].append(doc) + out = [] + for key, docs_in_group in grouped.items(): + record = {data.group_by or "group": key} + for metric in metrics: + values = [v for v in (get_nested(d, metric.field) for d in docs_in_group) if isinstance(v, (int, float))] + if metric.agg == "count": + record[_metric_key(metric)] = len(docs_in_group) + elif not values: + record[_metric_key(metric)] = None + elif metric.agg == "avg": + record[_metric_key(metric)] = sum(values) / len(values) + elif metric.agg == "sum": + record[_metric_key(metric)] = sum(values) + elif metric.agg == "min": + record[_metric_key(metric)] = min(values) + elif metric.agg == "max": + record[_metric_key(metric)] = max(values) + out.append(record) + out.sort(key=lambda r: str(r.get(data.group_by or "group"))) + return out + + +def _metric_key(metric: "MetricSpec") -> str: + return f"{metric.agg}_{metric.field}" if metric.field else metric.agg diff --git a/src/flowcept/webservice/services/streaming.py b/src/flowcept/webservice/services/streaming.py new file mode 100644 index 00000000..de20e8e5 --- /dev/null +++ b/src/flowcept/webservice/services/streaming.py @@ -0,0 +1,94 @@ +"""Incremental DB polling that backs the SSE live-stream endpoints. + +Tasks are updated in place (status/ended_at), so the cursor advances over the max of +``registered_at``/``started_at``/``ended_at``/``utc_timestamp`` seen so far, and each +poll matches any of those fields beyond the cursor. Works on Mongo natively and on +equality-only backends (LMDB) by applying the time predicate in Python. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.flowcept_api.db_api import DBAPI + +TASK_CURSOR_FIELDS = ("registered_at", "started_at", "ended_at", "utc_timestamp") +WORKFLOW_CURSOR_FIELDS = ("utc_timestamp",) + + +def _supports_operators() -> bool: + dao = DocumentDBDAO.get_instance(create_indices=False) + return hasattr(dao, "raw_pipeline") + + +def _as_epoch(value: Any) -> Optional[float]: + """Convert numeric or datetime time values to epoch seconds (DB stores both kinds).""" + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, datetime): + # Mongo returns naive UTC datetimes by default. + return (value if value.tzinfo else value.replace(tzinfo=timezone.utc)).timestamp() + return None + + +def _doc_cursor(doc: Dict[str, Any], fields: Tuple[str, ...]) -> float: + values = (_as_epoch(doc.get(f)) for f in fields) + return max((v for v in values if v is not None), default=0.0) + + +def poll_new_docs( + db: DBAPI, + collection: str, + base_filter: Dict[str, Any], + cursor: float, + max_batch: int, + cursor_fields: Optional[Tuple[str, ...]] = None, +) -> Tuple[List[Dict[str, Any]], float, bool]: + """Fetch docs newer than ``cursor`` and return ``(docs, new_cursor, truncated)``. + + Parameters + ---------- + db : DBAPI + DB API facade. + collection : str + ``tasks`` or ``workflows``. + base_filter : dict + Equality filter (e.g., ``{"workflow_id": ...}``); may be empty. + cursor : float + Epoch-seconds watermark; only docs with a time field beyond it are returned. + max_batch : int + Maximum docs returned per poll. + cursor_fields : tuple of str, optional + Time fields considered for the cursor. Defaults per collection. + + Returns + ------- + tuple + ``(docs, new_cursor, truncated)``. + """ + fields = cursor_fields or (TASK_CURSOR_FIELDS if collection == "tasks" else WORKFLOW_CURSOR_FIELDS) + + if _supports_operators(): + # Time fields are floats when inserted directly and BSON dates when persisted by + # the DocumentInserter; compare against both representations. + cursor_dt = datetime.fromtimestamp(cursor, timezone.utc) + time_clause = {"$or": [{f: {"$gt": cursor}} for f in fields] + [{f: {"$gt": cursor_dt}} for f in fields]} + query_filter = {"$and": [base_filter, time_clause]} if base_filter else time_clause + docs = db.query(collection=collection, filter=query_filter) or [] + else: + docs = db.query(collection=collection, filter=base_filter or None) or [] + docs = [d for d in docs if _doc_cursor(d, fields) > cursor] + + seen: Dict[str, Dict[str, Any]] = {} + id_field = "task_id" if collection == "tasks" else "workflow_id" + for doc in docs: + key = doc.get(id_field) or str(id(doc)) + seen[key] = doc + deduped = sorted(seen.values(), key=lambda d: _doc_cursor(d, fields)) + + truncated = len(deduped) > max_batch + batch = deduped[:max_batch] + new_cursor = max((_doc_cursor(d, fields) for d in batch), default=cursor) + return batch, max(new_cursor, cursor), truncated diff --git a/tests/agent/agent_tests.py b/tests/agent/agent_tests.py index 1c81f969..e943fe01 100644 --- a/tests/agent/agent_tests.py +++ b/tests/agent/agent_tests.py @@ -51,6 +51,53 @@ def test_loads_jsonl_buffer_when_mq_disabled(self): finally: agent.stop() + def test_mcp_db_backed_provenance_tools(self): + """The shared prov tools are exposed as MCP tools and query the real DB.""" + from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO + from flowcept.configs import MONGO_ENABLED + + if not MONGO_ENABLED: + FlowceptLogger().warning("Skipping MCP DB tools test because MongoDB is disabled.") + self.skipTest("MongoDB is disabled.") + if not Flowcept.services_alive(): + FlowceptLogger().warning("Skipping MCP DB tools test because services are not alive.") + self.skipTest("Flowcept services are not alive.") + + from uuid import uuid4 + + from flowcept.agents import flowcept_agent as agent_module + from flowcept.agents.agent_client import run_tool + from flowcept.instrumentation.task_capture import FlowceptTask + + campaign_id = f"mcp-campaign-{uuid4()}" + with Flowcept(campaign_id=campaign_id, workflow_name=f"mcp-tools-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="mcp_seed", used={"x": 1}) as task: + task.end(generated={"y": 2}) + + deadline = 20 + while deadline > 0 and not (Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []): + sleep(0.5) + deadline -= 1 + + agent = agent_module.FlowceptAgent() + agent.start() + try: + resp = run_tool("query_provenance_tasks", kwargs={"filter": {"workflow_id": workflow_id}})[0] + tool_result = ToolResult(**json.loads(resp)) + self.assertIn(tool_result.code, {201, 301}) + items = tool_result.result["items"] + self.assertTrue(any(t["activity_id"] == "mcp_seed" for t in items)) + + resp = run_tool("list_provenance_campaigns", kwargs={})[0] + tool_result = ToolResult(**json.loads(resp)) + self.assertIn(tool_result.code, {201, 301}) + self.assertTrue(any(c["campaign_id"] == campaign_id for c in tool_result.result["items"])) + finally: + agent.stop() + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + def test_llm_query_over_buffer(self): if not AGENT.get("api_key"): FlowceptLogger().warning("Skipping LLM agent query test because agent.api_key is not set.") diff --git a/tests/webservice/test_imports.py b/tests/webservice/test_imports.py new file mode 100644 index 00000000..dca9c0bd --- /dev/null +++ b/tests/webservice/test_imports.py @@ -0,0 +1,7 @@ +"""Basic import smoke test for webservice package.""" + + +def test_webservice_imports(): + from flowcept.webservice.main import app + + assert app is not None diff --git a/tests/webservice/test_webservice_api.py b/tests/webservice/test_webservice_api.py new file mode 100644 index 00000000..9e389fa4 --- /dev/null +++ b/tests/webservice/test_webservice_api.py @@ -0,0 +1,581 @@ +"""Webservice API tests with a mocked DBAPI dependency.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from flowcept.commons.flowcept_dataclasses.blob_object import BlobObject +from flowcept.commons.flowcept_dataclasses.workflow_object import WorkflowObject +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.main import create_app + + +class FakeDB: + """Simple fake DBAPI for endpoint tests.""" + + def __init__(self): + self.workflows = [ + {"workflow_id": "wf-1", "user": "alice", "campaign_id": "c1", "name": "run-a", "utc_timestamp": 200}, + {"workflow_id": "wf-2", "user": "bob", "campaign_id": "c2", "name": "run-b", "utc_timestamp": 100}, + ] + self.tasks = [ + {"task_id": "t2", "workflow_id": "wf-1", "status": "running", "started_at": 20}, + {"task_id": "t1", "workflow_id": "wf-1", "status": "finished", "started_at": 10}, + {"task_id": "t3", "workflow_id": "wf-2", "status": "finished", "started_at": 30}, + ] + self.objects = [ + { + "object_id": "o1", + "workflow_id": "wf-1", + "task_id": "t1", + "type": "dataset", + "version": 1, + "custom_metadata": {"k": "v1"}, + "data": b"payload-1", + "created_at": "2025-01-02T00:00:00", + }, + { + "object_id": "o2", + "workflow_id": "wf-2", + "task_id": "t3", + "type": "ml_model", + "version": 2, + "custom_metadata": {"k": "v2", "loss": 0.42}, + "data": b"payload-2", + "created_at": "2025-01-01T00:00:00", + }, + { + "object_id": "o3", + "workflow_id": "wf-1", + "task_id": "t2", + "type": "ml_model", + "version": 3, + "custom_metadata": {"k": "v3", "loss": 0.11}, + "data": b"payload-2", + "created_at": "2025-01-01T00:00:00", + }, + ] + + @staticmethod + def _nested_get(item, field): + value = item + for part in field.split("."): + if not isinstance(value, dict): + return None + value = value.get(part) + return value + + @classmethod + def _matches_filter(cls, item, filter_doc): + if not filter_doc: + return True + + for key, value in filter_doc.items(): + if key == "$and": + return all(cls._matches_filter(item, clause) for clause in value) + if key == "$or": + return any(cls._matches_filter(item, clause) for clause in value) + + field_value = cls._nested_get(item, key) + if isinstance(value, dict): + for op, expected in value.items(): + if op == "$exists": + exists = field_value is not None + if bool(expected) != exists: + return False + elif op == "$eq": + if field_value != expected: + return False + elif op == "$ne": + if field_value == expected: + return False + elif op == "$in": + if field_value not in expected: + return False + elif op == "$nin": + if field_value in expected: + return False + elif op == "$gt": + if field_value is None or not field_value > expected: + return False + elif op == "$gte": + if field_value is None or not field_value >= expected: + return False + elif op == "$lt": + if field_value is None or not field_value < expected: + return False + elif op == "$lte": + if field_value is None or not field_value <= expected: + return False + else: + raise ValueError(f"Unsupported fake operator in test DB: {op}") + else: + if field_value != value: + return False + return True + + def workflow_query(self, filter): + return [wf for wf in self.workflows if self._matches_filter(wf, filter)] + + def get_workflow_object(self, workflow_id): + for wf in self.workflows: + if wf["workflow_id"] == workflow_id: + return WorkflowObject.from_dict(wf) + return None + + def query(self, **kwargs): + collection = kwargs.get("collection") + filter_ = kwargs.get("filter") or {} + limit = kwargs.get("limit", 0) + projection = kwargs.get("projection") + sort = kwargs.get("sort") + + if collection == "workflows": + rs = [wf for wf in self.workflows if self._matches_filter(wf, filter_)] + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: self._nested_get(item, field), reverse=(order == -1)) + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + return rs[:limit] if limit else rs + + if collection == "objects": + rs = [obj for obj in self.objects if self._matches_filter(obj, filter_)] + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: self._nested_get(item, field), reverse=(order == -1)) + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + return rs[:limit] if limit else rs + + if collection == "tasks": + rs = [task for task in self.tasks if self._matches_filter(task, filter_)] + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: self._nested_get(item, field), reverse=(order == -1)) + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + return rs[:limit] if limit else rs + + return [] + + def task_query( + self, + filter, + projection=None, + limit=0, + sort=None, + aggregation=None, + remove_json_unserializables=True, + ): + rs = [task for task in self.tasks if self._matches_filter(task, filter or {})] + + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: item.get(field), reverse=(order == -1)) + + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + + return rs[:limit] if limit else rs + + def blob_object_query(self, filter): + return [obj for obj in self.objects if self._matches_filter(obj, filter or {})] + + def get_blob_object(self, object_id, version=None): + if version is None: + for obj in self.objects: + if obj["object_id"] == object_id: + return BlobObject.from_dict(obj) + raise ValueError(f"Object not found for object_id={object_id}.") + + for obj in self.objects: + if obj["object_id"] == object_id and obj["version"] == version: + return BlobObject.from_dict(obj) + + raise ValueError(f"Object not found for object_id={object_id}, version={version}.") + + def get_object_history(self, object_id): + return [ + {"object_id": object_id, "version": 2, "created_at": "2025-01-02T00:00:00"}, + {"object_id": object_id, "version": 1, "created_at": "2025-01-01T00:00:00"}, + ] + + +def build_client() -> tuple[TestClient, FakeDB]: + app = create_app() + fake_db = FakeDB() + app.dependency_overrides[get_db_api] = lambda: fake_db + return TestClient(app), fake_db + + +def test_root_and_openapi_endpoints(): + client, _ = build_client() + + root = client.get("/") + assert root.status_code == 200 + if root.headers["content-type"].startswith("application/json"): + # No built UI assets present: root exposes the API status payload. + assert root.json()["service"] == "flowcept-webservice" + else: + # Built UI assets present: root serves the SPA index page. + assert "text/html" in root.headers["content-type"] + + assert client.get("/openapi.json").status_code == 200 + assert client.get("/docs").status_code == 200 + assert client.get("/redoc").status_code == 200 + + +def test_health_endpoints(): + client, _ = build_client() + assert client.get("/api/v1/health/live").json() == {"status": "ok"} + assert client.get("/api/v1/health/ready").json() == {"status": "ready"} + + +def test_workflows_list_get_and_query(): + client, _ = build_client() + + rs = client.get("/api/v1/workflows", params={"limit": 10}) + assert rs.status_code == 200 + items = rs.json()["items"] + assert [item["workflow_id"] for item in items] == ["wf-2", "wf-1"] + + rs = client.get("/api/v1/workflows", params={"user": "alice", "limit": 5}) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert body["items"][0]["workflow_id"] == "wf-1" + + rs = client.get("/api/v1/workflows/wf-1") + assert rs.status_code == 200 + assert rs.json()["workflow_id"] == "wf-1" + + rs = client.post( + "/api/v1/workflows/query", + json={"filter": {"campaign_id": "c2"}, "limit": 10, "projection": ["workflow_id"]}, + ) + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + + +def test_workflow_provenance_card_download_route(): + client, _ = build_client() + + def _fake_generate_report(**kwargs): + output = kwargs["output_path"] + Path(output).write_text("# Provenance Card\n\nworkflow: wf-1\n", encoding="utf-8") + return {"output": output} + + with patch("flowcept.webservice.routers.workflows.Flowcept.generate_report", side_effect=_fake_generate_report): + rs = client.post("/api/v1/workflows/wf-1/reports/provenance-card/download") + + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/markdown") + assert "attachment; filename=\"provenance_card_wf-1.md\"" == rs.headers["content-disposition"] + assert "# Provenance Card" in rs.text + + +def test_workflows_errors(): + client, _ = build_client() + + rs = client.get("/api/v1/workflows/does-not-exist") + assert rs.status_code == 404 + + rs = client.get("/api/v1/workflows", params={"filter_json": "not-json"}) + assert rs.status_code == 400 + + rs = client.post("/api/v1/workflows/does-not-exist/reports/provenance-card/download") + assert rs.status_code == 404 + + +def test_workflow_provenance_card_download_generation_error(): + client, _ = build_client() + + with patch( + "flowcept.webservice.routers.workflows.Flowcept.generate_report", + side_effect=RuntimeError("report generation failed"), + ): + rs = client.post("/api/v1/workflows/wf-1/reports/provenance-card/download") + + assert rs.status_code == 500 + assert "Could not generate provenance card" in rs.json()["detail"] + + +def test_tasks_list_get_by_workflow_and_query(): + client, _ = build_client() + + rs = client.get("/api/v1/tasks", params={"workflow_id": "wf-1", "limit": 10}) + assert rs.status_code == 200 + assert rs.json()["count"] == 2 + assert [item["task_id"] for item in rs.json()["items"]] == ["t1", "t2"] + + rs = client.get("/api/v1/tasks/t1") + assert rs.status_code == 200 + assert rs.json()["task_id"] == "t1" + + rs = client.get("/api/v1/tasks/by_workflow/wf-2") + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + + rs = client.post( + "/api/v1/tasks/query", + json={ + "filter": {"workflow_id": "wf-1"}, + "sort": [{"field": "started_at", "order": -1}], + "projection": ["task_id", "started_at"], + "limit": 10, + }, + ) + assert rs.status_code == 200 + items = rs.json()["items"] + assert items[0]["started_at"] >= items[1]["started_at"] + + +def test_tasks_errors_and_validation(): + client, _ = build_client() + + rs = client.get("/api/v1/tasks/missing") + assert rs.status_code == 404 + + rs = client.get("/api/v1/tasks", params={"filter_json": "[]"}) + assert rs.status_code == 400 + + rs = client.post( + "/api/v1/tasks/query", + json={ + "filter": {}, + "projection": ["task_id", "workflow_id"], + "aggregation": [{"operator": "max", "field": "started_at"}], + "limit": 10, + }, + ) + assert rs.status_code == 400 + + +def test_objects_list_get_version_history_and_query(): + client, _ = build_client() + + rs = client.get("/api/v1/objects", params={"workflow_id": "wf-1", "limit": 10}) + assert rs.status_code == 200 + assert rs.json()["count"] == 2 + assert "data" not in rs.json()["items"][0] + + rs = client.get("/api/v1/objects", params={"limit": 10}) + assert rs.status_code == 200 + assert [item["object_id"] for item in rs.json()["items"]] == ["o2", "o3", "o1"] + + rs = client.get("/api/v1/objects/o1") + assert rs.status_code == 200 + assert rs.json()["object_id"] == "o1" + assert "data" not in rs.json() + + rs = client.get("/api/v1/objects/o1", params={"include_data": True}) + assert rs.status_code == 200 + assert isinstance(rs.json()["data"], str) + + rs = client.get("/api/v1/objects/o2/versions/2", params={"include_data": True}) + assert rs.status_code == 200 + assert rs.json()["version"] == 2 + + rs = client.get("/api/v1/objects/o1/download") + assert rs.status_code == 200 + assert rs.content == b"payload-1" + + rs = client.get("/api/v1/objects/o2/versions/2/download") + assert rs.status_code == 200 + assert rs.content == b"payload-2" + + rs = client.get("/api/v1/objects/o2/history", params={"limit": 1}) + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + + rs = client.post( + "/api/v1/objects/query", + json={ + "filter": {}, + "projection": ["object_id", "version"], + "sort": [{"field": "version", "order": -1}], + "limit": 1, + "include_data": False, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert set(body["items"][0].keys()) <= {"object_id", "version"} + + +def test_objects_errors_and_validation(): + client, _ = build_client() + + rs = client.get("/api/v1/objects/unknown") + assert rs.status_code == 404 + + rs = client.get("/api/v1/objects/o1/versions/99") + assert rs.status_code == 404 + + rs = client.get("/api/v1/objects", params={"filter_json": "not-json"}) + assert rs.status_code == 400 + + rs = client.post("/api/v1/objects/query", json={"filter": {}, "limit": 5001}) + assert rs.status_code == 422 + + +def test_datasets_routes(): + client, _ = build_client() + + rs = client.get("/api/v1/datasets") + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + assert rs.json()["items"][0]["type"] == "dataset" + + rs = client.get("/api/v1/datasets/o1") + assert rs.status_code == 200 + assert rs.json()["type"] == "dataset" + + rs = client.get("/api/v1/datasets/o1/versions/1") + assert rs.status_code == 200 + assert rs.json()["version"] == 1 + + rs = client.get("/api/v1/datasets/o1/download") + assert rs.status_code == 200 + assert rs.content == b"payload-1" + + rs = client.post("/api/v1/datasets/query", json={"filter": {}, "limit": 10}) + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + assert rs.json()["items"][0]["type"] == "dataset" + + rs = client.get("/api/v1/datasets/o2") + assert rs.status_code == 404 + + +def test_models_routes(): + client, _ = build_client() + + rs = client.get("/api/v1/models") + assert rs.status_code == 200 + assert rs.json()["count"] == 2 + assert rs.json()["items"][0]["type"] == "ml_model" + + rs = client.get("/api/v1/models/o2") + assert rs.status_code == 200 + assert rs.json()["type"] == "ml_model" + + rs = client.get("/api/v1/models/o2/versions/2") + assert rs.status_code == 200 + assert rs.json()["version"] == 2 + + rs = client.get("/api/v1/models/o2/download") + assert rs.status_code == 200 + assert rs.content == b"payload-2" + + rs = client.post("/api/v1/models/query", json={"filter": {}, "limit": 10}) + assert rs.status_code == 200 + assert rs.json()["count"] == 2 + assert rs.json()["items"][0]["type"] == "ml_model" + + rs = client.get("/api/v1/models/o1") + assert rs.status_code == 404 + + +def test_unified_scoped_query_models_supports_exists_and_nested_sort(): + client, _ = build_client() + + rs = client.post( + "/api/v1/query/models", + json={ + "filter": { + "workflow_id": "wf-1", + "custom_metadata.loss": {"$exists": True}, + }, + "sort": [{"field": "custom_metadata.loss", "order": 1}], + "projection": ["object_id", "type", "custom_metadata"], + "limit": 1, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert body["items"][0]["object_id"] == "o3" + assert body["items"][0]["type"] == "ml_model" + assert body["items"][0]["custom_metadata"]["loss"] == 0.11 + + +def test_unified_scoped_query_workflows_scope(): + client, _ = build_client() + rs = client.post( + "/api/v1/query/workflows", + json={ + "filter": {"campaign_id": "c1"}, + "projection": ["workflow_id", "campaign_id"], + "limit": 10, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert body["items"][0]["workflow_id"] == "wf-1" + + +def test_unified_scoped_query_tasks_scope(): + client, _ = build_client() + rs = client.post( + "/api/v1/query/tasks", + json={ + "filter": {"workflow_id": "wf-1"}, + "sort": [{"field": "started_at", "order": -1}], + "projection": ["task_id", "started_at"], + "limit": 1, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert body["items"][0]["task_id"] == "t2" + + +def test_unified_scoped_query_objects_scope(): + client, _ = build_client() + rs = client.post( + "/api/v1/query/objects", + json={ + "filter": {"type": "dataset"}, + "projection": ["object_id", "type"], + "limit": 10, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["count"] == 1 + assert body["items"][0]["object_id"] == "o1" + assert body["items"][0]["type"] == "dataset" + + +def test_unified_scoped_query_datasets_scope_enforces_type(): + client, _ = build_client() + rs = client.post( + "/api/v1/query/datasets", + json={ + "filter": {"type": "ml_model"}, + "limit": 10, + }, + ) + assert rs.status_code == 200 + assert rs.json()["count"] == 0 + + +def test_unified_scoped_query_rejects_unsupported_operator(): + client, _ = build_client() + rs = client.post( + "/api/v1/query/objects", + json={ + "filter": {"task_id": {"$where": "this.task_id == 't1'"}}, + "limit": 10, + }, + ) + assert rs.status_code == 400 + assert "Unsupported filter operator" in rs.json()["detail"] diff --git a/tests/webservice/test_webservice_integration.py b/tests/webservice/test_webservice_integration.py new file mode 100644 index 00000000..f9a46c71 --- /dev/null +++ b/tests/webservice/test_webservice_integration.py @@ -0,0 +1,765 @@ +"""Integration test for webservice routes backed by real Flowcept + MongoDB.""" + +from __future__ import annotations + +import json +import threading +import time +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from flowcept import Flowcept, FlowceptTask +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.commons.flowcept_dataclasses.task_object import TaskObject +from flowcept.configs import MONGO_ENABLED +from flowcept.webservice.main import create_app + + +pytestmark = pytest.mark.skipif(not MONGO_ENABLED, reason="MongoDB is disabled") + + +def _wait_for(condition, timeout_sec: float = 20.0, interval_sec: float = 0.25) -> bool: + deadline = time.time() + timeout_sec + while time.time() < deadline: + if condition(): + return True + time.sleep(interval_sec) + return False + + +def test_webservice_end_to_end_with_flowcept_and_blob_apis(): + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + workflow_name = f"ws-workflow-{uuid4()}" + + workflow_id = None + generic_obj_id = None + dataset_obj_id = None + model_obj_id = None + + with Flowcept(campaign_id=campaign_id, workflow_name=workflow_name): + with FlowceptTask(activity_id="ws_task", used={"x": 1}) as task: + task.end(generated={"y": 2}) + + workflow_id = Flowcept.current_workflow_id + + generic_obj_id = Flowcept.db.save_or_update_object( + object=b"generic-blob-payload", + type="artifact", + save_data_in_collection=True, + custom_metadata={"kind": "generic"}, + ) + + dataset_obj_id = Flowcept.db.save_or_update_dataset( + object=b"dataset-blob-payload", + save_data_in_collection=True, + custom_metadata={"split": "train"}, + ) + + model_obj_id = Flowcept.db.save_or_update_ml_model( + object=b"model-blob-payload", + save_data_in_collection=True, + custom_metadata={"framework": "sklearn"}, + ) + + assert workflow_id is not None + assert generic_obj_id is not None + assert dataset_obj_id is not None + assert model_obj_id is not None + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + task_doc = (Flowcept.db.task_query(filter={"workflow_id": workflow_id}, limit=1) or [None])[0] + assert task_doc is not None + task_id = task_doc["task_id"] + + app = create_app() + client = TestClient(app) + + # Workflows: list/get/query including campaign_id filter support. + rs = client.get("/api/v1/workflows", params={"campaign_id": campaign_id}) + assert rs.status_code == 200 + wf_items = rs.json()["items"] + assert any(item["workflow_id"] == workflow_id for item in wf_items) + + rs = client.get(f"/api/v1/workflows/{workflow_id}") + assert rs.status_code == 200 + assert rs.json()["campaign_id"] == campaign_id + + rs = client.post("/api/v1/workflows/query", json={"filter": {"campaign_id": campaign_id}, "limit": 10}) + assert rs.status_code == 200 + assert any(item["workflow_id"] == workflow_id for item in rs.json()["items"]) + + # Tasks: list/get/query. + rs = client.get("/api/v1/tasks", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + rs = client.get(f"/api/v1/tasks/{task_id}") + assert rs.status_code == 200 + assert rs.json()["workflow_id"] == workflow_id + + rs = client.post("/api/v1/tasks/query", json={"filter": {"workflow_id": workflow_id}, "limit": 10}) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + # Objects: list/get/query/download. + rs = client.get("/api/v1/objects", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + assert rs.json()["count"] >= 3 + + rs = client.get(f"/api/v1/objects/{generic_obj_id}") + assert rs.status_code == 200 + assert rs.json()["object_id"] == generic_obj_id + + rs = client.post("/api/v1/objects/query", json={"filter": {"workflow_id": workflow_id}, "limit": 20}) + assert rs.status_code == 200 + assert any(item["object_id"] == generic_obj_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/objects/{generic_obj_id}/download") + assert rs.status_code == 200 + assert rs.content == b"generic-blob-payload" + + # Datasets: list/get/query/download. + rs = client.get("/api/v1/datasets", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + assert any(item["object_id"] == dataset_obj_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/datasets/{dataset_obj_id}") + assert rs.status_code == 200 + assert rs.json()["type"] == "dataset" + + rs = client.post("/api/v1/datasets/query", json={"filter": {"workflow_id": workflow_id}, "limit": 20}) + assert rs.status_code == 200 + assert any(item["object_id"] == dataset_obj_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/datasets/{dataset_obj_id}/download") + assert rs.status_code == 200 + assert rs.content == b"dataset-blob-payload" + + # Models: list/get/query/download. + rs = client.get("/api/v1/models", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + assert any(item["object_id"] == model_obj_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/models/{model_obj_id}") + assert rs.status_code == 200 + assert rs.json()["type"] == "ml_model" + + rs = client.post("/api/v1/models/query", json={"filter": {"workflow_id": workflow_id}, "limit": 20}) + assert rs.status_code == 200 + assert any(item["object_id"] == model_obj_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/models/{model_obj_id}/download") + assert rs.status_code == 200 + assert rs.content == b"model-blob-payload" + + # Cleanup singleton client handles for test isolation. + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_campaigns_agents_stats_and_prov_card(): + """End-to-end test for derived campaigns/agents, stats endpoints, and provenance cards.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + workflow_name = f"ws-stats-workflow-{uuid4()}" + agent_id = f"ws-agent-{uuid4()}" + + with Flowcept(campaign_id=campaign_id, workflow_name=workflow_name): + workflow_id = Flowcept.current_workflow_id + for i in range(3): + with FlowceptTask(activity_id="preprocess", used={"i": i}) as task: + task.end(generated={"out": i * 2}) + with FlowceptTask(activity_id="train", used={"epochs": 2}, agent_id=agent_id) as task: + task.end(generated={"loss": 0.1}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 4) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + + # Campaigns: derived list and detail. + rs = client.get("/api/v1/campaigns") + assert rs.status_code == 200 + campaigns = {item["campaign_id"]: item for item in rs.json()["items"]} + assert campaign_id in campaigns + assert campaigns[campaign_id]["workflow_count"] >= 1 + assert campaigns[campaign_id]["task_count"] >= 4 + + rs = client.get(f"/api/v1/campaigns/{campaign_id}") + assert rs.status_code == 200 + body = rs.json() + assert any(wf["workflow_id"] == workflow_id for wf in body["workflows"]) + assert body["task_summary"]["count"] >= 4 + + rs = client.get(f"/api/v1/campaigns/non-existent-{uuid4()}") + assert rs.status_code == 404 + + # Agents: derived from task agent_id. + rs = client.get("/api/v1/agents") + assert rs.status_code == 200 + assert any(item["agent_id"] == agent_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/agents/{agent_id}") + assert rs.status_code == 200 + assert rs.json()["agent"]["task_count"] == 1 + assert "train" in rs.json()["agent"]["activities"] + + rs = client.get(f"/api/v1/agents/{agent_id}/tasks") + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + + # Stats: task summary, timeseries, and card-data resolver. + rs = client.get("/api/v1/stats/tasks/summary", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + summary = rs.json() + assert summary["count"] >= 4 + activities = {a["activity_id"]: a for a in summary["activity_stats"]} + assert activities["preprocess"]["count"] == 3 + assert activities["train"]["count"] == 1 + assert summary["time_range"]["min_started_at"] is not None + + rs = client.post( + "/api/v1/stats/timeseries", + json={"filter": {"workflow_id": workflow_id}, "fields": ["ended_at"], "x": "started_at"}, + ) + assert rs.status_code == 200 + assert rs.json()["count"] >= 4 + assert all(row["started_at"] is not None for row in rs.json()["rows"]) + + rs = client.post( + "/api/v1/stats/card_data", + json={ + "data": { + "source": "tasks", + "group_by": "activity_id", + "metrics": [{"field": "", "agg": "count"}], + }, + "context": {"workflow_id": workflow_id}, + }, + ) + assert rs.status_code == 200 + rows = {row["activity_id"]: row for row in rs.json()["rows"]} + assert rows["preprocess"]["count"] == 3 + assert rows["train"]["count"] == 1 + + # Rejected operator must 400. + rs = client.get("/api/v1/stats/tasks/summary", params={"filter_json": '{"$where": "1"}'}) + assert rs.status_code == 400 + + # Provenance card: JSON and markdown content. + rs = client.get(f"/api/v1/workflows/{workflow_id}/provenance_card", params={"format": "json"}) + assert rs.status_code == 200 + card = rs.json() + assert card["input_mode"] == "db" + assert "transformations" in card and "dataset" in card + + rs = client.get(f"/api/v1/workflows/{workflow_id}/provenance_card", params={"format": "markdown"}) + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/markdown") + assert workflow_name in rs.text or workflow_id in rs.text + + rs = client.get(f"/api/v1/campaigns/{campaign_id}/provenance_card", params={"format": "markdown"}) + assert rs.status_code == 200 + + rs = client.get(f"/api/v1/workflows/{workflow_id}/provenance_card", params={"format": "pdf"}) + assert rs.status_code == 400 + + # Cleanup singleton client handles for test isolation. + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_object_versioning_and_unified_query(): + """End-to-end test for object version history and the unified /query/{scope} endpoint.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + obj_id = f"ws-versioned-{uuid4()}" + + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-version-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="emit", used={"x": 1}) as task: + task.end(generated={"y": 1}) + for version in range(2): + Flowcept.db.save_or_update_object( + object=f"payload-v{version}".encode(), + object_id=obj_id, + type="ml_model", + save_data_in_collection=True, + custom_metadata={"v": version}, + control_version=True, + ) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + + # Version history and per-version metadata/downloads. + rs = client.get(f"/api/v1/objects/{obj_id}/history") + assert rs.status_code == 200 + versions = sorted(item["version"] for item in rs.json()["items"]) + assert versions == [0, 1] + + rs = client.get(f"/api/v1/objects/{obj_id}/versions/0") + assert rs.status_code == 200 + assert rs.json()["custom_metadata"]["v"] == 0 + + rs = client.get(f"/api/v1/objects/{obj_id}/versions/0/download") + assert rs.status_code == 200 + assert rs.content == b"payload-v0" + + rs = client.get(f"/api/v1/objects/{obj_id}/download") + assert rs.status_code == 200 + assert rs.content == b"payload-v1" + + # Models scope sees the versioned object; include_data exposes payload. + rs = client.get(f"/api/v1/models/{obj_id}", params={"include_data": "true"}) + assert rs.status_code == 200 + assert rs.json()["type"] == "ml_model" + assert rs.json().get("data") + + # Unified scoped query: operators, sort, projection, limit. + rs = client.post( + "/api/v1/query/tasks", + json={ + "filter": {"workflow_id": workflow_id, "started_at": {"$exists": True}}, + "projection": ["task_id", "activity_id", "started_at"], + "sort": [{"field": "started_at", "order": -1}], + "limit": 5, + }, + ) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + assert all("used" not in item for item in rs.json()["items"]) + + rs = client.post("/api/v1/query/models", json={"filter": {"object_id": obj_id}, "limit": 5}) + assert rs.status_code == 200 + assert all(item["type"] == "ml_model" for item in rs.json()["items"]) + + # Disallowed operator is rejected. + rs = client.post("/api/v1/query/tasks", json={"filter": {"$where": "1"}, "limit": 5}) + assert rs.status_code == 400 + + # Tasks by workflow + filter_json list filters. + rs = client.get(f"/api/v1/tasks/by_workflow/{workflow_id}") + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + rs = client.get("/api/v1/tasks", params={"filter_json": f'{{"workflow_id": "{workflow_id}"}}'}) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + # Legacy provenance-card download endpoint still works. + rs = client.post(f"/api/v1/workflows/{workflow_id}/reports/provenance-card/download") + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/markdown") + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_dashboards_crud(): + """End-to-end CRUD test for dashboards stored in the real backend.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + app = create_app() + client = TestClient(app) + + spec = { + "name": f"dash-{uuid4()}", + "description": "integration test dashboard", + "context": {"campaign_id": "camp-1"}, + "cards": [ + { + "card_id": "c1", + "type": "chart", + "title": "Tasks per activity", + "data": { + "source": "tasks", + "group_by": "activity_id", + "metrics": [{"field": "", "agg": "count"}], + }, + "viz": {"kind": "bar"}, + }, + {"card_id": "c2", "type": "markdown", "content": "# Notes"}, + ], + "layout": [ + {"card_id": "c1", "x": 0, "y": 0, "w": 6, "h": 4}, + {"card_id": "c2", "x": 6, "y": 0, "w": 6, "h": 4}, + ], + } + + rs = client.post("/api/v1/dashboards", json=spec) + assert rs.status_code == 201 + created = rs.json() + dashboard_id = created["dashboard_id"] + assert dashboard_id and created["created_at"] + + try: + rs = client.get(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 200 + assert rs.json()["name"] == spec["name"] + assert len(rs.json()["cards"]) == 2 + + rs = client.get("/api/v1/dashboards") + assert rs.status_code == 200 + assert any(d["dashboard_id"] == dashboard_id for d in rs.json()["items"]) + + updated = dict(spec, description="updated") + rs = client.put(f"/api/v1/dashboards/{dashboard_id}", json=updated) + assert rs.status_code == 200 + assert rs.json()["description"] == "updated" + assert rs.json()["created_at"] == created["created_at"] + assert rs.json()["updated_at"] >= created["updated_at"] + + # Spec validation: bad card type and disallowed filter operator must be rejected. + bad = dict(spec, cards=[{"card_id": "x", "type": "nope"}]) + rs = client.post("/api/v1/dashboards", json=bad) + assert rs.status_code == 422 + + bad = dict(spec, context={"$where": "1"}) + rs = client.post("/api/v1/dashboards", json=bad) + assert rs.status_code == 400 + finally: + rs = client.delete(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 200 + + rs = client.get(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 404 + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def _start_real_server(app): + """Run the app on a real uvicorn server in a thread; return (server, thread, base_url).""" + import socket + + import uvicorn + from sse_starlette.sse import AppStatus + + # sse-starlette's exit Event binds to the first serving loop; reset per server (see + # FlowceptAgent._run_server for the same workaround). + AppStatus.should_exit_event = None + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + assert _wait_for(lambda: server.started, timeout_sec=15), "Webservice did not start." + return server, thread, f"http://127.0.0.1:{port}" + + +def _stop_real_server(server, thread): + server.should_exit = True + thread.join(timeout=10) + + +def _read_sse_events(line_iter, max_events: int, timeout_sec: float = 15.0): + """Collect up to ``max_events`` parsed SSE events from an iterator of lines.""" + events = [] + current_event, current_data = None, [] + deadline = time.time() + timeout_sec + for line in line_iter: + if time.time() > deadline: + break + if line.startswith("event:"): + current_event = line.split(":", 1)[1].strip() + elif line.startswith("data:"): + current_data.append(line.split(":", 1)[1].strip()) + elif line == "" and current_event: + events.append((current_event, json.loads("".join(current_data) or "null"))) + current_event, current_data = None, [] + if len(events) >= max_events: + break + return events + + +def test_webservice_stream_tasks_sse(): + """End-to-end SSE: existing tasks arrive in the first event; mid-stream inserts arrive next.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-sse-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="sse_seed", used={"i": 0}) as task: + task.end(generated={"o": 0}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + import httpx + + server, server_thread, base_url = _start_real_server(create_app()) + + late_task_id = f"sse-late-{uuid4()}" + + def _insert_late_task(): + time.sleep(0.8) + now = time.time() + task = TaskObject() + task.task_id = late_task_id + task.workflow_id = workflow_id + task.activity_id = "sse_late" + task.started_at = now + task.ended_at = now + task.registered_at = now + Flowcept.db.insert_or_update_task(task) + + inserter = threading.Thread(target=_insert_late_task, daemon=True) + + try: + with httpx.stream( + "GET", + f"{base_url}/api/v1/stream/tasks?workflow_id={workflow_id}&since=0&poll_interval=0.2", + timeout=httpx.Timeout(20.0), + ) as rs: + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/event-stream") + inserter.start() + events = _read_sse_events(rs.iter_lines(), max_events=2) + finally: + _stop_real_server(server, server_thread) + + assert len(events) == 2 + name0, payload0 = events[0] + assert name0 == "tasks" + assert any(t["activity_id"] == "sse_seed" for t in payload0["tasks"]) + assert payload0["cursor"] > 0 + + name1, payload1 = events[1] + assert name1 == "tasks" + assert any(t["task_id"] == late_task_id for t in payload1["tasks"]) + assert payload1["cursor"] >= payload0["cursor"] + + inserter.join(timeout=5) + + # Cursor semantics: since= + a fresh insert returns only the new task. + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_stream_workflows_sse(): + """End-to-end SSE for the workflows stream filtered by campaign.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-sse-wf2-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + + ok = _wait_for(lambda: len(Flowcept.db.workflow_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted workflow." + + import httpx + + server, server_thread, base_url = _start_real_server(create_app()) + try: + with httpx.stream( + "GET", + f"{base_url}/api/v1/stream/workflows?campaign_id={campaign_id}&since=0&poll_interval=0.2", + timeout=httpx.Timeout(20.0), + ) as rs: + assert rs.status_code == 200 + events = _read_sse_events(rs.iter_lines(), max_events=1) + finally: + _stop_real_server(server, server_thread) + + assert len(events) == 1 + name, payload = events[0] + assert name == "workflows" + assert any(w["workflow_id"] == workflow_id for w in payload["workflows"]) + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_spa_serving(tmp_path, monkeypatch): + """SPA assets are served at root with index.html fallback when present.""" + from flowcept.webservice import main as ws_main + + # Without assets: root returns the API status payload. + missing_dir = tmp_path / "no_ui" + monkeypatch.setattr(ws_main, "Path", lambda *_: missing_dir / "main.py") + client = TestClient(ws_main.create_app()) + rs = client.get("/") + assert rs.status_code == 200 + assert rs.json()["service"] == "flowcept-webservice" + + # With real assets on disk: index.html served at root and for SPA routes; API still wins. + ui_dir = tmp_path / "ui_build" + (ui_dir / "assets").mkdir(parents=True) + (ui_dir / "index.html").write_text("flowcept-ui") + (ui_dir / "assets" / "app.js").write_text("console.log('ui')") + + monkeypatch.setattr(ws_main, "Path", lambda *_: tmp_path / "main.py") + client = TestClient(ws_main.create_app()) + + rs = client.get("/") + assert rs.status_code == 200 + assert "flowcept-ui" in rs.text + + rs = client.get("/workflows/some-id") + assert "flowcept-ui" in rs.text + + rs = client.get("/assets/app.js") + assert rs.status_code == 200 + assert "console.log" in rs.text + + rs = client.get("/api/v1/health/live") + assert rs.status_code == 200 + assert rs.json() != {} + + rs = client.get("/api/v1/this/does/not/exist") + assert rs.status_code == 404 + + +def test_prov_tools_shared_core(): + """The shared provenance tool core (used by web chat and MCP agent) works on real data.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + from flowcept.agents.tools.prov_tools import ( + get_task_summary, + list_campaigns, + make_chart, + query_tasks, + query_workflows, + ) + + campaign_id = f"ws-campaign-{uuid4()}" + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-tools-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="tool_seed", used={"x": 1}) as task: + task.end(generated={"y": 2}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + result = query_tasks(filter={"workflow_id": workflow_id}, limit=10) + assert result.code in (201, 301) + assert any(t["activity_id"] == "tool_seed" for t in result.result["items"]) + + result = query_workflows(filter={"campaign_id": campaign_id}) + assert result.code in (201, 301) + assert any(w["workflow_id"] == workflow_id for w in result.result["items"]) + + result = get_task_summary(filter={"workflow_id": workflow_id}) + assert result.result["count"] >= 1 + + result = list_campaigns() + assert any(c["campaign_id"] == campaign_id for c in result.result["items"]) + + result = make_chart( + card_spec={ + "card_id": "chat-c1", + "type": "chart", + "title": "tasks per activity", + "data": {"source": "tasks", "filter": {"workflow_id": workflow_id}, "group_by": "activity_id"}, + "viz": {"kind": "bar"}, + } + ) + assert result.code in (201, 301) + assert result.result["rows"] + assert result.result["card"]["card_id"] == "chat-c1" + + # Disallowed filter operators are rejected by the shared core. + result = query_tasks(filter={"$where": "1"}, limit=10) + assert result.code >= 400 + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_chat_endpoint_unavailable_without_llm(): + """POST /api/v1/chat returns 503 with a clear detail when no LLM is configured.""" + from flowcept.configs import AGENT + + api_key = AGENT.get("api_key") + if api_key and api_key != "?": + pytest.skip("An LLM is configured; the 503 path does not apply.") + + app = create_app() + client = TestClient(app) + rs = client.post("/api/v1/chat", json={"messages": [{"role": "user", "content": "hi"}]}) + assert rs.status_code == 503 + assert "LLM" in rs.json()["detail"] or "llm" in rs.json()["detail"] + + +def test_chat_endpoint_real_llm_tool_roundtrip(): + """Real LLM chat round-trip: the model must call a query tool and answer (env-gated).""" + from flowcept.commons.flowcept_logger import FlowceptLogger + from flowcept.configs import AGENT + + api_key = AGENT.get("api_key") + if not api_key or api_key == "?": + FlowceptLogger().warning("Skipping real-LLM chat test because agent.api_key is not set.") + pytest.skip("agent.api_key is not set.") + if not AGENT.get("service_provider") or AGENT.get("service_provider") == "?": + FlowceptLogger().warning("Skipping real-LLM chat test because agent.service_provider is not set.") + pytest.skip("agent.service_provider is not set.") + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-chat-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + for i in range(3): + with FlowceptTask(activity_id="chat_seed", used={"i": i}) as task: + task.end(generated={"o": i}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 3) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + rs = client.post( + "/api/v1/chat", + json={ + "messages": [{"role": "user", "content": "How many tasks ran in this workflow?"}], + "context": {"workflow_id": workflow_id}, + "stream": False, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["message"] + assert any("3" in str(part) for part in (body["message"], body.get("tool_trace", []))) + assert body.get("tool_trace"), "Expected the LLM to call at least one tool." + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_file_dashboard_store_roundtrip(tmp_path): + """FileDashboardStore (non-Mongo fallback) persists real JSON files.""" + from flowcept.webservice.services.dashboard_store import FileDashboardStore + + store = FileDashboardStore(directory=str(tmp_path)) + doc = {"dashboard_id": "d1", "name": "local", "cards": [], "layout": []} + assert store.save(doc) + assert store.get("d1")["name"] == "local" + assert any(d["dashboard_id"] == "d1" for d in store.list()) + assert store.delete("d1") + assert store.get("d1") is None + assert store.delete("d1") is False diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..d843386e --- /dev/null +++ b/ui/README.md @@ -0,0 +1,158 @@ +# Flowcept Web UI + +React single-page app for browsing and analyzing Flowcept provenance data: campaigns, +workflows, tasks, artifacts (datasets/ML models), and agents — with live (SSE) updates, +user-defined dashboards, and an embedded LLM chat that queries the provenance database +and renders charts. + +The UI is served by the Flowcept webservice (FastAPI, `src/flowcept/webservice/`). The built +assets are emitted into the Python package (`src/flowcept/webservice/ui_build/`) so released +wheels ship the UI; end users need no Node toolchain. + +## Stack + +| Concern | Library | +|---|---| +| Build/dev server | Vite + TypeScript (strict) | +| UI framework | React 18 | +| Styling | Tailwind CSS 4 (dark theme via CSS variables in `src/index.css`) | +| Routing | TanStack Router (file-based routes, typed search params — all view state is in the URL) | +| Server state | TanStack Query | +| Tables | TanStack Table + Virtual (virtualized task tables) | +| Charts | Apache ECharts (`echarts/core`, tree-shaken; thin wrapper in `components/charts/EChart.tsx`) | +| Dashboards grid | react-grid-layout v2 (drag/resize) | +| Markdown | react-markdown + remark-gfm (provenance cards, chat) | +| SSE | @microsoft/fetch-event-source (supports POST bodies for chat) | +| Validation | zod (dashboard specs, search params) | +| Ephemeral state | zustand (chat panel) | + +## Code layout + +``` +ui/ + vite.config.ts # dev proxy (/api → :5000), build.outDir → ../src/flowcept/webservice/ui_build + src/ + main.tsx # router + query client setup + index.css # Tailwind theme tokens (colors, card/prose utility classes) + api/ + client.ts # fetch wrapper for /api/v1 (apiGet/apiPost/apiPut/apiDelete) + types.ts # hand-maintained API types (regenerate: npm run gen-api-types) + queries.ts # TanStack Query hooks (useCampaigns, useWorkflow, useTasksQuery, ...) + sse.ts # useEventStream: SSE hook w/ cursor resume, backoff, tab-pause + lib/format.ts # toEpochSec/fmtTs/fmtDuration/statusColor (handles float AND ISO times) + stores/chatStore.ts # chat transcript + panel state + components/ + charts/ # EChart wrapper, GanttChart (custom series), StatusStrip, TelemetryChart + tables/DataTable.tsx# virtualized generic table + markdown/, JsonTree.tsx, tasks/TaskDrawer.tsx + dashboard/ + spec.ts # zod mirror of webservice schemas/dashboards.py (DashboardSpec/Card/CardData) + specToOption.ts # declarative card spec + rows → ECharts option + CardRenderer.tsx # per-type card rendering; data via POST /api/v1/stats/card_data + chat/ChatPanel.tsx # streams POST /api/v1/chat SSE events into rich message parts + routes/ # file-based pages: __root (shell+sidebar+chat), index (overview), + # campaigns, workflows.$workflowId (tasks/timeline/telemetry/card/raw tabs), + # tasks.$taskId, objects, agents, dashboards.$dashboardId (grid editor) +``` + +Data-flow summary: + +- Pages call REST endpoints under `/api/v1` (see `src/flowcept/webservice/docs/API_CONTRACT.md`). +- Live mode (the `LIVE` toggle on a workflow page, or `live: true` dashboard cards) uses + `GET /api/v1/stream/tasks` — SSE backed by incremental DB polling; the `cursor` in each event + resumes the stream after reconnects. +- Dashboards are JSON specs stored server-side (`/api/v1/dashboards`); each card declares a + data binding (`CardData`) resolved by `POST /api/v1/stats/card_data` and mapped to ECharts. +- Chat (`POST /api/v1/chat`) streams `tool_call`, `tool_result`, `card`, and `token` events; + `card` events render as inline ECharts. Queries are scoped to the page being viewed + (workflow/campaign/dashboard id is sent as context). + +## Running + +### Prerequisites + +- Flowcept installed with the webservice extra (e.g., `pip install -e .[webservice]`, plus + `[llm_agent]` for chat/agent and your DB extras such as `[mongo]`). +- Services up (e.g., `make services-mongo` for Redis + MongoDB). +- Node 22+ and npm only if you build or develop the UI yourself. + +### Production-style (single server) + +```bash +make ui-install # once: npm ci +make ui-build # builds into src/flowcept/webservice/ui_build +flowcept --start-webservice # serves UI + API on web_server.host:port (default :5000) +# open http://localhost:5000 (REST docs at /docs) +``` + +If the built assets are missing, the webservice logs a warning and serves the API only. + +### Development (hot reload) + +```bash +# terminal 1 — API: +uvicorn flowcept.webservice.main:app --port 5000 --reload +# terminal 2 — UI dev server (proxies /api to :5000): +make ui-dev # http://localhost:5173 +``` + +Dev-server ports are configurable via env vars (override defaults without editing files): + +| Variable | Default | Purpose | +|---|---|---| +| `WEBSERVER_HOST` | `0.0.0.0` | FastAPI bind host (Python, also honoured by `flowcept --start-webservice`) | +| `WEBSERVER_PORT` | `5000` | FastAPI bind port | +| `VITE_API_HOST` | `localhost` | API host the Vite proxy forwards `/api` requests to | +| `VITE_API_PORT` | `5000` | API port the Vite proxy forwards to | +| `VITE_DEV_PORT` | `5173` | Vite dev server listen port | + +Example — API on a non-default port: +```bash +WEBSERVER_PORT=8080 uvicorn flowcept.webservice.main:app --port 8080 --reload +VITE_API_PORT=8080 make ui-dev +``` + +Type-check/build verification: `make ui-checks` / `make ui-build`. + +### Enabling the chat (LLM) + +The chat endpoint reuses the `agent` section of your Flowcept settings (`~/.flowcept/settings.yaml` +or `FLOWCEPT_SETTINGS_PATH`): + +```yaml +agent: + enabled: true + service_provider: openai # sambanova | azure | openai | google + llm_server_url: + api_key: + model: +web_server: + chat: + enabled: true + max_tool_iterations: 5 + max_query_limit: 1000 +``` + +Without this, `POST /api/v1/chat` returns 503 and the rest of the UI works normally. +The LLM answers with real DB-backed tools (query tasks/workflows, task summaries, campaigns, +agents, chart building) — the same shared tool core used by the MCP agent +(`src/flowcept/agents/tools/prov_tools.py`). + +### Running the MCP agent alongside + +The MCP agent is a separate server for external agent clients (Claude Code, Codex, etc.) and +live in-memory analysis. It now also exposes the DB-backed provenance tools +(`query_provenance_tasks`, `list_provenance_campaigns`, ...): + +```bash +flowcept --start-agent # MCP (streamable HTTP) on agent.mcp_host:mcp_port (default :8000) +``` + +The web UI does not require the agent; the chat panel talks to the webservice directly. + +## Tests + +End-to-end integration tests (real services, no mocks) live in +`tests/webservice/test_webservice_integration.py` (REST, SSE, dashboards, prov cards, chat — +the LLM round-trip runs when `agent.api_key` is configured) and +`tests/agent/agent_tests.py` (MCP tools against a running agent). diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..3db4487e --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Flowcept + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..8af3da08 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,4985 @@ +{ + "name": "flowcept-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flowcept-ui", + "version": "0.1.0", + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-table": "^8.20.0", + "@tanstack/react-virtual": "^3.11.0", + "date-fns": "^4.1.0", + "echarts": "^5.5.0", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", + "react-markdown": "^9.0.0", + "remark-gfm": "^4.0.0", + "zod": "^3.24.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.95.0", + "@types/node": "^25.9.2", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "openapi-typescript": "^7.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "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.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz", + "integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.17", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.17.tgz", + "integrity": "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.18.tgz", + "integrity": "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-generator": "1.167.17", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.15", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-plugin/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbot": { + "version": "5.1.42", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.42.tgz", + "integrity": "sha512-/SXsVh7KpPRISrD4ffrGSxnTLlUBzEQUfWIusaJPrpJ93FW1P0YEZri5vAUkFsA0m2HRUhQRQadk2wJ+EeKowQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-draggable": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz", + "integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz", + "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..9fd117b0 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,43 @@ +{ + "name": "flowcept-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc --noEmit", + "lint": "tsc --noEmit", + "preview": "vite preview", + "gen-api-types": "openapi-typescript http://${VITE_API_HOST:-localhost}:${VITE_API_PORT:-5000}/openapi.json -o src/api/types.gen.ts" + }, + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-table": "^8.20.0", + "@tanstack/react-virtual": "^3.11.0", + "date-fns": "^4.1.0", + "echarts": "^5.5.0", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", + "react-markdown": "^9.0.0", + "remark-gfm": "^4.0.0", + "zod": "^3.24.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.95.0", + "@types/node": "^25.9.2", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "openapi-typescript": "^7.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 00000000..8bae797f --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,55 @@ +/** Thin fetch wrapper for the Flowcept API under /api/v1. */ + +export const API_BASE = "/api/v1"; + +export class ApiError extends Error { + status: number; + constructor(status: number, detail: string) { + super(detail); + this.status = status; + } +} + +async function handle(rs: Response): Promise { + if (!rs.ok) { + let detail = rs.statusText; + try { + const body = await rs.json(); + detail = body.detail ?? JSON.stringify(body); + } catch { + /* keep statusText */ + } + throw new ApiError(rs.status, detail); + } + return rs.json() as Promise; +} + +export async function apiGet(path: string, params?: Record): Promise { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params ?? {})) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + return handle(await fetch(url)); +} + +export async function apiSend(method: string, path: string, body?: unknown): Promise { + return handle( + await fetch(API_BASE + path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); +} + +export const apiPost = (path: string, body?: unknown) => apiSend("POST", path, body); +export const apiPut = (path: string, body?: unknown) => apiSend("PUT", path, body); +export const apiDelete = (path: string) => apiSend("DELETE", path); + +export async function apiGetText(path: string, params?: Record): Promise { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, v); + const rs = await fetch(url); + if (!rs.ok) throw new ApiError(rs.status, rs.statusText); + return rs.text(); +} diff --git a/ui/src/api/queries.ts b/ui/src/api/queries.ts new file mode 100644 index 00000000..579ba12c --- /dev/null +++ b/ui/src/api/queries.ts @@ -0,0 +1,103 @@ +/** TanStack Query hooks for Flowcept API resources. */ + +import { useQuery } from "@tanstack/react-query"; +import { apiGet, apiGetText, apiPost } from "./client"; +import type { + AgentSummary, + BlobObjectDoc, + Campaign, + ListResponse, + QueryRequest, + Task, + TaskSummary, + Workflow, +} from "./types"; + +export function useCampaigns() { + return useQuery({ + queryKey: ["campaigns"], + queryFn: () => apiGet>("/campaigns"), + }); +} + +export function useCampaign(campaignId: string) { + return useQuery({ + queryKey: ["campaign", campaignId], + queryFn: () => + apiGet<{ campaign: Campaign; workflows: Workflow[]; task_summary: TaskSummary }>(`/campaigns/${campaignId}`), + }); +} + +export function useWorkflows(params: { campaign_id?: string; limit?: number } = {}) { + return useQuery({ + queryKey: ["workflows", params], + queryFn: () => apiGet>("/workflows", { limit: 200, ...params }), + }); +} + +export function useWorkflow(workflowId: string) { + return useQuery({ + queryKey: ["workflow", workflowId], + queryFn: () => apiGet(`/workflows/${workflowId}`), + }); +} + +export function useTasksQuery(body: QueryRequest, enabled = true) { + return useQuery({ + queryKey: ["tasks", body], + queryFn: () => apiPost>("/tasks/query", body), + enabled, + }); +} + +export function useTask(taskId: string) { + return useQuery({ + queryKey: ["task", taskId], + queryFn: () => apiGet(`/tasks/${taskId}`), + }); +} + +export function useTaskSummary(params: { workflow_id?: string; campaign_id?: string; agent_id?: string }) { + return useQuery({ + queryKey: ["taskSummary", params], + queryFn: () => apiGet("/stats/tasks/summary", params), + }); +} + +export function useObjects(params: { workflow_id?: string; type?: string } = {}) { + const path = params.type === "ml_model" ? "/models" : params.type === "dataset" ? "/datasets" : "/objects"; + return useQuery({ + queryKey: ["objects", params], + queryFn: () => apiGet>(path, { workflow_id: params.workflow_id }), + }); +} + +export function useObject(objectId: string) { + return useQuery({ + queryKey: ["object", objectId], + queryFn: () => apiGet(`/objects/${objectId}`), + }); +} + +export function useObjectHistory(objectId: string) { + return useQuery({ + queryKey: ["objectHistory", objectId], + queryFn: () => apiGet>(`/objects/${objectId}/history`), + }); +} + +export function useAgents() { + return useQuery({ + queryKey: ["agents"], + queryFn: () => apiGet>("/agents"), + }); +} + +export function useProvenanceCard(scope: "workflows" | "campaigns", id: string, enabled = true) { + return useQuery({ + queryKey: ["provCard", scope, id], + queryFn: () => apiGetText(`/${scope}/${id}/provenance_card`, { format: "markdown" }), + enabled, + staleTime: 60_000, + }); +} diff --git a/ui/src/api/sse.ts b/ui/src/api/sse.ts new file mode 100644 index 00000000..6673606c --- /dev/null +++ b/ui/src/api/sse.ts @@ -0,0 +1,61 @@ +/** SSE hook for the /api/v1/stream endpoints: cursor resume, backoff reconnect, tab-pause. */ + +import { useEffect, useRef } from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE } from "./client"; + +interface StreamOptions { + path: string; // e.g. "/stream/tasks" + params: Record; + event: string; // e.g. "tasks" + enabled: boolean; + onBatch: (docs: T[], cursor: number) => void; +} + +export function useEventStream({ path, params, event, enabled, onBatch }: StreamOptions) { + const cursorRef = useRef(0); + const onBatchRef = useRef(onBatch); + onBatchRef.current = onBatch; + const paramsKey = JSON.stringify(params); + + useEffect(() => { + if (!enabled) return; + const ctrl = new AbortController(); + let retryMs = 1000; + + const connect = () => { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) url.searchParams.set(k, v); + } + if (cursorRef.current > 0) url.searchParams.set("since", String(cursorRef.current)); + + void fetchEventSource(url.toString(), { + signal: ctrl.signal, + openWhenHidden: false, // pause when tab hidden; resumes from cursor on visible + onmessage(msg) { + if (msg.event !== event || !msg.data) return; + try { + const payload = JSON.parse(msg.data) as Record; + const docs = (payload[event] as T[]) ?? []; + const cursor = (payload["cursor"] as number) ?? cursorRef.current; + cursorRef.current = cursor; + retryMs = 1000; + if (docs.length) onBatchRef.current(docs, cursor); + } catch { + /* ignore malformed events */ + } + }, + onerror() { + // Exponential backoff with jitter; fetchEventSource retries after the throw delay. + retryMs = Math.min(retryMs * 2, 30_000); + return retryMs + Math.random() * 500; + }, + }); + }; + + connect(); + return () => ctrl.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path, paramsKey, event, enabled]); +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts new file mode 100644 index 00000000..ed2e341a --- /dev/null +++ b/ui/src/api/types.ts @@ -0,0 +1,109 @@ +/** Hand-maintained API types for the MVP (regenerable later via `npm run gen-api-types`). */ + +export interface ListResponse> { + items: T[]; + count: number; + limit: number; +} + +export interface Workflow { + workflow_id: string; + name?: string; + campaign_id?: string; + parent_workflow_id?: string; + user?: string; + utc_timestamp?: number; + flowcept_version?: string; + sys_name?: string; + environment_id?: string; + code_repository?: Record; + used?: Record; + generated?: Record; + custom_metadata?: Record; + [key: string]: unknown; +} + +export interface Task { + task_id: string; + workflow_id?: string; + parent_task_id?: string; + campaign_id?: string; + activity_id?: string; + agent_id?: string; + status?: string; + subtype?: string; + hostname?: string; + user?: string; + started_at?: number | string; + ended_at?: number | string; + utc_timestamp?: number | string; + registered_at?: number | string; + used?: Record; + generated?: Record; + stdout?: unknown; + stderr?: unknown; + tags?: string[]; + telemetry_at_start?: Record; + telemetry_at_end?: Record; + [key: string]: unknown; +} + +export interface BlobObjectDoc { + object_id: string; + workflow_id?: string; + task_id?: string; + type?: string; + version?: number; + custom_metadata?: Record; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface Campaign { + campaign_id: string; + workflow_count: number; + task_count: number; + users: string[]; + workflow_names: string[]; + first_ts?: number | null; + last_ts?: number | null; +} + +export interface AgentSummary { + agent_id: string; + task_count: number; + activities: string[]; + source_agent_ids: string[]; + campaign_ids: string[]; + last_active?: number | null; +} + +export interface ActivityStat { + activity_id: string | null; + count: number; + status_counts: Record; + avg_duration?: number | null; + min_duration?: number | null; + max_duration?: number | null; + sum_duration?: number | null; +} + +export interface TaskSummary { + count: number; + status_counts: Record; + activity_stats: ActivityStat[]; + time_range: { min_started_at?: number | null; max_ended_at?: number | null }; +} + +export interface QueryRequest { + filter?: Record; + projection?: string[]; + limit?: number; + sort?: { field: string; order: 1 | -1 }[]; +} + +export interface CardDataResult { + rows: Record[]; + count: number; +} diff --git a/ui/src/components/JsonTree.tsx b/ui/src/components/JsonTree.tsx new file mode 100644 index 00000000..3bb1cb7e --- /dev/null +++ b/ui/src/components/JsonTree.tsx @@ -0,0 +1,46 @@ +/** Compact collapsible JSON viewer for used/generated/telemetry payloads. */ + +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +function Entry({ name, value, depth }: { name: string; value: unknown; depth: number }) { + const [open, setOpen] = useState(depth < 1); + const isObj = value !== null && typeof value === "object"; + + if (!isObj) { + return ( +
+ {name}: + {value === null ? "null" : String(value)} +
+ ); + } + + const entries = Array.isArray(value) + ? value.map((v, i) => [String(i), v] as [string, unknown]) + : Object.entries(value as Record); + + return ( +
+ + {open && entries.map(([k, v]) => )} +
+ ); +} + +export function JsonTree({ data, name = "root" }: { data: unknown; name?: string }) { + if (data === null || data === undefined) return
; + return ( +
+ +
+ ); +} diff --git a/ui/src/components/charts/EChart.tsx b/ui/src/components/charts/EChart.tsx new file mode 100644 index 00000000..fee6d60f --- /dev/null +++ b/ui/src/components/charts/EChart.tsx @@ -0,0 +1,77 @@ +/** Thin ECharts wrapper with theme defaults and auto-resize. */ + +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { BarChart, LineChart, PieChart, ScatterChart, CustomChart, HeatmapChart } from "echarts/charts"; +import { + DatasetComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + TitleComponent, + TooltipComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { EChartsCoreOption, ECharts } from "echarts/core"; + +echarts.use([ + BarChart, + LineChart, + PieChart, + ScatterChart, + CustomChart, + HeatmapChart, + DatasetComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + TitleComponent, + TooltipComponent, + CanvasRenderer, +]); + +const THEME_DEFAULTS: EChartsCoreOption = { + backgroundColor: "transparent", + textStyle: { color: "#8b93a7", fontSize: 11 }, + color: ["#4f8cff", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#22d3ee", "#fb923c"], +}; + +interface Props { + option: EChartsCoreOption; + height?: number | string; + onClick?: (params: { data?: unknown; name?: string }) => void; + className?: string; +} + +export function EChart({ option, height = 280, onClick, className }: Props) { + const ref = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const chart = echarts.init(ref.current); + chartRef.current = chart; + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(ref.current); + return () => { + observer.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + chartRef.current?.setOption({ ...THEME_DEFAULTS, ...option }, { notMerge: true }); + }, [option]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !onClick) return; + chart.on("click", onClick); + return () => { + chart.off("click"); + }; + }, [onClick]); + + return
; +} diff --git a/ui/src/components/charts/GanttChart.tsx b/ui/src/components/charts/GanttChart.tsx new file mode 100644 index 00000000..61f7dfd4 --- /dev/null +++ b/ui/src/components/charts/GanttChart.tsx @@ -0,0 +1,97 @@ +/** Task timeline (gantt) rendered with an ECharts custom series. */ + +import { useMemo } from "react"; +import type { Task } from "../../api/types"; +import { statusColor, fmtDuration, fmtTs, toEpochSec } from "../../lib/format"; +import { EChart } from "./EChart"; + +const MAX_BARS = 5000; + +interface Props { + tasks: Task[]; + onTaskClick?: (taskId: string) => void; +} + +export function GanttChart({ tasks, onTaskClick }: Props) { + const { option, truncated } = useMemo(() => { + const usable = tasks + .map((t) => ({ t, start: toEpochSec(t.started_at), end: toEpochSec(t.ended_at) })) + .filter((r) => r.start !== null) + .sort((a, b) => (a.start ?? 0) - (b.start ?? 0)); + const sliced = usable.slice(0, MAX_BARS); + const now = Date.now() / 1000; + const rows = sliced.map(({ t, start, end }, i) => ({ + value: [i, (start as number) * 1000, (end ?? now) * 1000], + itemStyle: { color: statusColor(t.status) }, + task: t, + })); + const opt = { + grid: { left: 8, right: 16, top: 8, bottom: 40, containLabel: false }, + xAxis: { + type: "time" as const, + axisLine: { lineStyle: { color: "#232a3b" } }, + splitLine: { lineStyle: { color: "#181d2a" } }, + }, + yAxis: { type: "value" as const, min: -1, max: Math.max(rows.length, 5), show: false, inverse: true }, + dataZoom: [ + { type: "inside" as const, filterMode: "weakFilter" as const }, + { type: "slider" as const, height: 18, bottom: 8, borderColor: "#232a3b" }, + ], + tooltip: { + formatter: (p: { data?: { task?: Task } }) => { + const t = p.data?.task; + if (!t) return ""; + const start = toEpochSec(t.started_at); + const end = toEpochSec(t.ended_at); + const dur = start !== null && end !== null ? fmtDuration(end - start) : "running"; + return [ + `${t.activity_id ?? t.task_id}`, + `status: ${t.status ?? "?"}`, + `start: ${fmtTs(t.started_at)}`, + `duration: ${dur}`, + ].join("
"); + }, + }, + series: [ + { + type: "custom" as const, + encode: { x: [1, 2], y: 0 }, + data: rows, + renderItem: ( + _params: unknown, + api: { + value: (i: number) => number; + coord: (v: number[]) => number[]; + style: () => Record; + }, + ) => { + const idx = api.value(0); + const start = api.coord([api.value(1), idx]); + const end = api.coord([api.value(2), idx]); + const h = 6; + return { + type: "rect", + shape: { x: start[0], y: start[1] - h / 2, width: Math.max(end[0] - start[0], 2), height: h }, + style: api.style(), + }; + }, + }, + ], + }; + return { option: opt, truncated: usable.length > MAX_BARS }; + }, [tasks]); + + return ( +
+ {truncated &&
Showing first {MAX_BARS} tasks.
} + { + const task = (p as { data?: { task?: Task } }).data?.task; + if (task && onTaskClick) onTaskClick(task.task_id); + }} + /> +
+ ); +} diff --git a/ui/src/components/charts/StatusStrip.tsx b/ui/src/components/charts/StatusStrip.tsx new file mode 100644 index 00000000..7d0cbdc6 --- /dev/null +++ b/ui/src/components/charts/StatusStrip.tsx @@ -0,0 +1,61 @@ +/** Horizontal stacked status counts + per-activity stats table. */ + +import type { TaskSummary } from "../../api/types"; +import { fmtDuration, statusColor } from "../../lib/format"; + +export function StatusStrip({ summary }: { summary: TaskSummary }) { + const total = summary.count || 1; + const entries = Object.entries(summary.status_counts); + return ( +
+
+
+ {entries.map(([status, count]) => ( +
+ ))} +
+
{summary.count} tasks
+
+
+ {entries.map(([status, count]) => ( + + + {status} {count} + + ))} +
+ {summary.activity_stats.length > 0 && ( + + + + + + + + + + + + + {summary.activity_stats.map((a) => ( + + + + + + + + + ))} + +
ActivityCountAvgMinMaxErrors
{a.activity_id ?? "—"}{a.count}{fmtDuration(a.avg_duration)}{fmtDuration(a.min_duration)}{fmtDuration(a.max_duration)} + {a.status_counts["ERROR"] ?? 0} +
+ )} +
+ ); +} diff --git a/ui/src/components/charts/TelemetryChart.tsx b/ui/src/components/charts/TelemetryChart.tsx new file mode 100644 index 00000000..9d8c2809 --- /dev/null +++ b/ui/src/components/charts/TelemetryChart.tsx @@ -0,0 +1,96 @@ +/** Telemetry timeseries over tasks with a metric picker, backed by /stats/timeseries. */ + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiPost } from "../../api/client"; +import { fmtTs, toEpochSec, type TimeValue } from "../../lib/format"; +import { EChart } from "./EChart"; + +const METRICS: Record = { + "CPU %": "telemetry_at_end.cpu.percent_all", + "Memory used": "telemetry_at_end.memory.virtual.used", + "Process CPU %": "telemetry_at_end.process.cpu_percent", + "Process memory %": "telemetry_at_end.process.memory_percent", + "Disk read bytes": "telemetry_at_end.disk.io.read_bytes", + "Net bytes sent": "telemetry_at_end.network.netio.bytes_sent", +}; + +export function TelemetryChart({ filter }: { filter: Record }) { + const [metric, setMetric] = useState("CPU %"); + const field = METRICS[metric]; + + const { data, isLoading } = useQuery({ + queryKey: ["timeseries", filter, field], + queryFn: () => + apiPost<{ rows: Record[]; count: number }>("/stats/timeseries", { + filter, + fields: [field], + x: "started_at", + limit: 2000, + }), + }); + + const option = useMemo(() => { + const rows = (data?.rows ?? []).filter((r) => r[field] !== null && r[field] !== undefined); + return { + grid: { left: 60, right: 16, top: 24, bottom: 28 }, + xAxis: { + type: "time" as const, + axisLine: { lineStyle: { color: "#232a3b" } }, + splitLine: { show: false }, + }, + yAxis: { + type: "value" as const, + axisLine: { lineStyle: { color: "#232a3b" } }, + splitLine: { lineStyle: { color: "#181d2a" } }, + }, + tooltip: { + trigger: "item" as const, + formatter: (p: { data?: [number, number, string] }) => + p.data ? `${p.data[2]}
${fmtTs(p.data[0] / 1000)}
${p.data[1]}` : "", + }, + series: [ + { + type: "scatter" as const, + symbolSize: 7, + data: rows + .map((r) => [ + (toEpochSec(r["started_at"] as TimeValue) ?? 0) * 1000, + r[field] as number, + (r["activity_id"] as string) ?? (r["task_id"] as string), + ]) + .filter((d) => d[0] !== 0), + }, + ], + }; + }, [data, field]); + + const hasData = (data?.rows ?? []).some((r) => r[field] !== null && r[field] !== undefined); + + return ( +
+
+ {Object.keys(METRICS).map((m) => ( + + ))} +
+ {isLoading ? ( +
Loading…
+ ) : hasData ? ( + + ) : ( +
+ No telemetry values for this metric (telemetry capture may be disabled). +
+ )} +
+ ); +} diff --git a/ui/src/components/chat/ChatPanel.tsx b/ui/src/components/chat/ChatPanel.tsx new file mode 100644 index 00000000..5c7f833c --- /dev/null +++ b/ui/src/components/chat/ChatPanel.tsx @@ -0,0 +1,164 @@ +/** Provenance chat panel: streams /api/v1/chat SSE events into rich message parts. */ + +import { useEffect, useRef, useState } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { Eraser, Send, Wrench, X } from "lucide-react"; +import { API_BASE } from "../../api/client"; +import { useChatStore, type ChatMsg } from "../../stores/chatStore"; +import { EChart } from "../charts/EChart"; +import { Markdown } from "../markdown/Markdown"; +import { specToOption } from "../dashboard/specToOption"; + +function routeContext(pathname: string): Record { + const wf = pathname.match(/\/workflows\/([^/?]+)/); + if (wf) return { workflow_id: decodeURIComponent(wf[1]) }; + const camp = pathname.match(/\/campaigns\/([^/?]+)/); + if (camp) return { campaign_id: decodeURIComponent(camp[1]) }; + const dash = pathname.match(/\/dashboards\/([^/?]+)/); + if (dash) return { dashboard_id: decodeURIComponent(dash[1]) }; + return {}; +} + +export function ChatPanel() { + const { open, busy, messages, toggle, setBusy, push, appendPart, reset } = useChatStore(); + const [input, setInput] = useState(""); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [messages]); + + if (!open) return null; + + const send = async () => { + const text = input.trim(); + if (!text || busy) return; + setInput(""); + const history = [...messages, { role: "user", parts: [{ kind: "text", text }] } as ChatMsg]; + push({ role: "user", parts: [{ kind: "text", text }] }); + setBusy(true); + + const apiMessages = history.map((m) => ({ + role: m.role, + content: m.parts + .filter((p) => p.kind === "text") + .map((p) => (p.kind === "text" ? p.text : "")) + .join("\n"), + })); + + try { + await fetchEventSource(`${API_BASE}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: apiMessages, + context: routeContext(pathname), + stream: true, + allow_dashboard_edit: pathname.startsWith("/dashboards/"), + }), + openWhenHidden: true, + onmessage(msg) { + if (!msg.event) return; + const data = msg.data ? JSON.parse(msg.data) : null; + if (msg.event === "token" && data) appendPart({ kind: "text", text: String(data) }); + if (msg.event === "tool_call" && data) appendPart({ kind: "tool", name: data.name, args: data.args }); + if (msg.event === "card" && data?.card) appendPart({ kind: "card", data }); + if (msg.event === "error" && data) appendPart({ kind: "text", text: `⚠️ ${data}` }); + }, + onerror(err) { + appendPart({ kind: "text", text: `⚠️ Chat request failed: ${err}` }); + throw err; // stop retrying + }, + }); + } catch { + /* error already surfaced in the transcript */ + } finally { + setBusy(false); + } + }; + + return ( +