diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..82cef9fc30f9afb8af56213e1a9268268521c025 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gemini +.vercel + +backend/.env +backend/venv +backend/__pycache__ +backend/**/__pycache__ +backend/**/*.pyc + +frontend/.env +frontend/node_modules +frontend/dist + +*.log diff --git a/.gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py b/.gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py new file mode 100644 index 0000000000000000000000000000000000000000..e55e2da83811f3a419924ecfb98b195e43c59472 --- /dev/null +++ b/.gemini/antigravity/brain/8453b74f-68a6-47ae-887d-1123cb011afb/scratch/verify_supabase.py @@ -0,0 +1,10 @@ +import sys +import os +sys.path.append(os.path.join(os.getcwd(), 'backend')) + +try: + from backend.services.supabase_service import supabase + res = supabase.table("agents").select("count").execute() + print(f"Connection successful! Agents count: {res.data}") +except Exception as e: + print(f"Error connecting to Supabase: {e}") diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a21957cf7f6ea03844fd2a6dced7ea422c8381b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +backend/.env +/backend/venv +/backend/__pycache__ +frontend/.env +node_modules/ +dist/ +__pycache__/ +*.pyc + +.vercel +frontend/ios +frontend/android +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4105bdf0e020db36a29398003974d4306def3d8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:22-slim AS frontend-build + +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend ./ + +ARG VITE_API_URL="" +ARG VITE_SUPABASE_URL="" +ARG VITE_SUPABASE_ANON_KEY="" +ARG VITE_SENTRY_DSN="" + +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL +ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY +ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN + +RUN npm run build + +FROM python:3.11-slim + +ENV PORT=7860 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY VERSION VERSION +COPY backend/requirements.txt backend/requirements.txt +RUN pip install --no-cache-dir -r backend/requirements.txt + +COPY backend backend +COPY --from=frontend-build /app/frontend/dist frontend/dist + +WORKDIR /app/backend + +EXPOSE 7860 + +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b4fe7af68c7ff7a10994da86ecbd591a0b93d29d --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +--- +title: Aubm +sdk: docker +app_port: 7860 +license: mit +short_description: Automated Business Machines +--- + +# Aubm + +Enterprise-grade AI agent orchestration and collaboration platform. + +Aubm turns complex goals into supervised multi-agent workflows: projects, context, agents, tasks, dependencies, human approvals, reports, and operational monitoring in one workspace. + +## Key Features + +- Multi-provider LLM support through backend provider adapters. +- Project wizard for Guided and Expert creation flows. +- Agent marketplace for deploying reusable specialist agents. +- Task orchestration with priorities, dependencies, retries, and human approval. +- Multi-agent debate for cross-reviewing task outputs. +- Final reports: full report, short brief, pessimistic analysis, and PDF export. +- Project roadmap view inferred from task status, priority, and dependencies. +- Completed project locking: completed projects become read-only in the UI and backend mutation endpoints. +- Monitoring dashboard with backend health and Supabase fallback metrics. +- Voice control and spatial task visualization for expert workflows. +- Sentry-compatible error tracking hooks for backend and frontend. + +See [ROADMAP.md](./ROADMAP.md) for the current implementation status. The roadmap is intentionally conservative and separates completed, partial, in-progress, and next work. + +## Tech Stack + +- Frontend: React + Vite + TypeScript + vanilla CSS. +- Backend: FastAPI on Python 3.10+. +- Database/Auth: Supabase Postgres + Supabase Auth. +- Deployment: Docker, Hugging Face Spaces, and Vercel configuration. + +## Project Structure + +```text +aubm/ + backend/ FastAPI app, agents, routers, services, worker + database/ Supabase schema and migrations + docs/ Operating guide, audit notes, task plan, sales one-pager + frontend/ React/Vite app + ROADMAP.md Current product roadmap and status + SPEC.md Technical specification +``` + +## Database Setup + +For a fresh Supabase project, apply: + +```text +database/schema.sql +database/seed.sql +database/phase3_updates.sql +database/marketplace.sql +database/enterprise_security.sql +database/add_team_permissions.sql +database/agent_ownership.sql +database/task_owner_policies.sql +database/default_agents.sql +``` + +For existing projects, also apply any migration that matches your current error or missing capability: + +```text +database/add_task_run_duration.sql +database/add_task_queued_status.sql +database/add_task_queue_leasing.sql +database/add_task_queue_retry_backoff.sql +database/add_worker_heartbeats.sql +database/add_audit_mutation_triggers.sql +database/add_task_claims.sql +database/add_profile_manager_role.sql +database/fix_profiles_rls_final.sql +database/fix_profiles_recursion.sql +database/add_team_permissions.sql +``` + +After schema changes, reload PostgREST when the migration includes: + +```sql +NOTIFY pgrst, 'reload schema'; +``` + +## Backend Setup + +```powershell +cd backend +python -m venv venv +.\venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Create `backend/.env`: + +```env +SUPABASE_URL=your_project_url +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +OPENAI_API_KEY=optional_key +GROQ_API_KEY=optional_key +GEMINI_API_KEY=optional_key +AMD_API_KEY=optional_key +TAVILY_API_KEY=optional_key +SENTRY_DSN=optional_dsn +``` + +## Frontend Setup + +```powershell +cd frontend +npm install +npm run dev +``` + +Create `frontend/.env`: + +```env +VITE_API_URL=http://127.0.0.1:8000 +VITE_SUPABASE_URL=your_project_url +VITE_SUPABASE_ANON_KEY=your_anon_key +VITE_SENTRY_DSN=optional_dsn +``` + +Validation: + +```powershell +cd frontend +npm run lint +npm run build +``` + +## Worker + +A lightweight worker scaffold exists: + +```powershell +cd backend +python worker.py +``` + +The worker uses `tasks.status = 'queued'` and atomically claims jobs with `claim_next_queued_task`. Existing databases must apply: + +```text +database/add_task_queued_status.sql +database/add_task_queue_leasing.sql +database/add_task_queue_retry_backoff.sql +database/add_worker_heartbeats.sql +database/add_audit_mutation_triggers.sql +``` + +Worker retry behavior can be tuned with: + +```env +AUBM_WORKER_MAX_ATTEMPTS=3 +AUBM_WORKER_RETRY_DELAY_SECONDS=30 +``` + +To route task/project execution through the worker, set: + +```env +TASK_EXECUTION_MODE=queue +``` + +With `TASK_QUEUE_EMBEDDED_WORKER=true` (the default), the FastAPI process starts an embedded worker when queue mode is enabled. Set `TASK_QUEUE_EMBEDDED_WORKER=false` when running separate worker processes with `python worker.py`. + +Without queue mode, execution remains direct/background for local development. Individual calls can opt into queue mode with `?use_queue=true`. + +## Hugging Face Spaces + +This repo can run as a Docker Space. Create a Hugging Face Space with SDK `Docker`, push this repo, and configure secrets: + +```env +SUPABASE_URL=your_project_url +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +SUPABASE_ANON_KEY=your_anon_key +GROQ_API_KEY=optional_key +OPENAI_API_KEY=optional_key +GEMINI_API_KEY=optional_key +AMD_API_KEY=optional_key +TAVILY_API_KEY=optional_key +SENTRY_DSN=optional_dsn +``` + +`VITE_API_URL` can stay empty on Spaces when the frontend and FastAPI backend share the same origin. + +## Documentation + +- [SPEC.md](./SPEC.md): Technical architecture and contracts. +- [ROADMAP.md](./ROADMAP.md): Current implementation status and next work. +- [docs/OPERATING_GUIDE.md](./docs/OPERATING_GUIDE.md): Operational usage and setup. +- [docs/AUTH_MODEL.md](./docs/AUTH_MODEL.md): Enterprise authentication and OAuth policy. +- [docs/TASK_SCHEMAS.md](./docs/TASK_SCHEMAS.md): Structured task output schema rules. +- [docs/MIGRATION_GUIDE.md](./docs/MIGRATION_GUIDE.md): Existing Supabase project migrations. +- [docs/TASKS.md](./docs/TASKS.md): Implementation task tracker. +- [docs/AUDIT.md](./docs/AUDIT.md): Stability and risk audit. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000000000000000000000000000000000000..98406bf98af66da0930a934730dde19fde71c8ee --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,72 @@ +# Aubm Roadmap + +This document tracks the practical evolution of Aubm from a working multi-agent orchestrator into an enterprise-ready operating layer. Status is intentionally conservative: + +- Completed: implemented and visible in the product or backend. +- Partial: scaffolded or implemented in a limited form, but not production-complete. +- Next: planned work with no complete implementation yet. + +## Phase 1: Core Foundation (Completed) +- [x] Autonomous Agent Execution: Multi-provider support for configured LLM providers. +- [x] Project Orchestration: Project-level task execution with dependency-aware planning support. +- [x] Human-in-the-Loop: Approval and rejection workflows for agent outputs. +- [x] Project Context Injection: Project descriptions, context, notes, files, and links are passed into planning/execution. +- [x] Final Reporting: Full, brief, pessimistic, and PDF report flows. + +## Phase 2: Collaboration and Operator Workflow (Completed) +- [x] Multi-Agent Debates: Agents can cross-review and refine task output before human review. +- [x] Agent Marketplace: Deploy reusable agent templates into a user's workspace. +- [x] Voice Interaction: Browser voice APIs can control navigation and read project/task status. +- [x] Spatial Dashboard: Layered project/task visualization for DAG-style inspection. +- [x] Guided and Expert Creation Wizard: Step-by-step project creation with explanations. +- [x] Project Roadmap View: Read-only roadmap modal inferred from task status, priority, and dependencies. + +## Phase 3: Production Operations (Completed) +- [x] Operations Monitoring: Backend health endpoint and frontend monitoring dashboard with Supabase fallback. +- [x] Deployment Hardening: Dockerized backend/runtime profile and production CORS configuration. +- [x] Error Tracking Hooks: Sentry-compatible backend and frontend initialization. +- [x] Performance Budgeting: Frontend code splitting and bundle-size-aware build output. +- [x] Completed Project Locking: Completed projects are read-only in the UI and guarded by backend mutation checks. + +## Phase 4: Security, Governance, and Data Quality (Partial) +- [x] Row-Level Security: Core Supabase RLS policies for projects, tasks, agents, profiles, marketplace templates, and admin access. +- [x] Admin and Manager Roles: Profile role support includes user, manager, and admin. +- [x] Profile Role Protection: Final profile RLS migration uses non-recursive admin checks and a trigger to block non-admin role escalation. +- [x] Audit Log Schema: Audit table and service exist. +- [/] Audit Log Coverage: Backend task runs, queue retries, approvals, debates, decomposition, and report generation write audit events; a trigger migration covers direct project, task, agent, and profile mutations. +- [/] Team Permissions: `teams`, `team_members`, project `team_id`, owner-or-team RLS policies, and team-aware evidence reads are available through migration; frontend/backend workflows still need full team-aware UX/API coverage. +- [x] SSO State: Google/GitHub buttons remain hidden by default, and the enterprise auth model is documented in `docs/AUTH_MODEL.md`. + +## Phase 5: Async Execution and Scale (Complete) +- [x] Worker Scaffold: `backend/worker.py` and `TaskQueueService` exist. +- [x] Queued Task Status: `tasks.status` now supports `queued` for background workers. +- [x] Queue Safety: Workers claim queued tasks through an atomic Postgres lease function. +- [x] Worker Observability: Worker heartbeats, queue depth, stale leases, and active worker counts are visible in Monitoring. +- [x] Retry Policy: Queue attempts, exponential backoff, delayed retries, and terminal failure reasons are stored. +- [x] Worker Integration: Task and project run endpoints can route work to the queue with `TASK_EXECUTION_MODE=queue` or `use_queue=true`. +- [x] Queue Default: Sync execution is now fallback; queue mode is default in development and production. + +## Phase 6: Evidence and Entity Integrity (Complete) +- [x] Strict JSON Task Schemas: Backend classifies structured task types, prompts for JSON, and blocks approval when required fields are missing. +- [x] Semantic Deduplication: Extracted claims use normalized text hashes and embedding-based semantic merging to avoid duplicates per project. +- [x] Mandatory `source_url` per Claim: Structured factual/comparison outputs require source URLs and extracted claims are stored in `task_claims`; approval is blocked if sources are missing for sensitive schemas. +- [x] Entity Normalization Layer: `task_claims` stores normalized `entity_key` values; new `EvidenceView` component provides a unified UI for semantic findings and entity intelligence. +- [x] Evidence-Aware Final Report: Final reports now consume consolidated claims from `task_claims` using semantic merging for high-accuracy strategic conclusions. + +## Phase 7: Intelligence and Memory (Next) +- [x] Vectorized Long-Term Memory: Cross-project semantic retrieval over approved outputs and source material; implemented via `project_memory` and `match_project_memory` RPC. +- [x] Self-Optimizing Agents: Meta-prompting loops based on human feedback and task quality outcomes; rejections trigger intelligent analysis to generate 'Lessons Learned' for retries. +- [x] Cost Control: Project budgets, estimated usage events, and pre-run execution blocking are implemented; provider-native token usage tracking ensures billing-grade pricing reconciliation. +- [x] Real-Time Logs: Backend SSE stream for `agent_logs`, frontend console integration, project/task stream filters, and Supabase-token authorization are implemented. +- [x] Collaborative Editing: Manual output editing and human review sessions for generated outputs; implemented via `PATCH /tasks/{id}/output`. + +## Phase 8: Enterprise Multi-Tenancy & Governance (Complete) +- [x] Team Management UI: Full interface for creating teams, inviting members, and assigning roles (admin, editor, viewer). +- [x] Team-Aware Project Creation: Select team workspaces during project setup to enable shared context and RLS-enforced collaboration. +- [x] Audit Explorer: Searchable and filterable UI for system-wide audit logs, including metadata inspection and deep links. +- [x] Bulk Audit Export: Download audit logs as CSV for compliance and external reporting. +- [x] Role-Based Marketplace: Teams can publish and share internal agent templates within their own workspace; implemented via `team_id` on templates and AgentsView sharing. + +--- + +*Last updated: May 7, 2026* diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000000000000000000000000000000000000..3935bede59f8bbe6d0622a1f430037ae564bf990 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,200 @@ +# Aubm Technical Specification + +Target stack: FastAPI + React/TypeScript + Supabase. + +This document describes the current product architecture and the contracts that matter for development. For status and sequencing, see [ROADMAP.md](./ROADMAP.md). + +## 1. Architecture + +Aubm uses Supabase as the source of truth for users, projects, agents, tasks, templates, and execution records. + +```text +backend/ + main.py FastAPI entrypoint + worker.py Polling worker scaffold for queued tasks + agents/ LLM provider adapters + routers/ + agent_runner.py Task run, approve, reject, approve-all + orchestrator.py Debate, project run, report, PDF export + services/ + orchestrator_service.py Project orchestration and report building + agent_runner_service.py Task execution and task_runs persistence + task_queue.py Lightweight queued-task helper + output_quality.py Heuristic output quality checks + semantic_backprop.py Prior completed-output context builder + tools/ Tool registry and tool implementations + +frontend/ + src/components/ Dashboard, project detail, marketplace, settings, monitoring + src/services/ Supabase, runtime config, LLM defaults, UI mode + src/context/ Auth context + +database/ + schema.sql Baseline schema + *.sql Idempotent migrations and seed files +``` + +## 2. Database + +### Core Tables + +| Table | Purpose | +| --- | --- | +| `profiles` | User metadata and role: `user`, `manager`, `admin`. | +| `projects` | Project containers with owner, context, status, visibility. | +| `agents` | Deployed agents owned by users or global templates. | +| `agent_templates` | Marketplace agent templates. | +| `tasks` | Units of work with status, priority, assigned agent, output data. | +| `task_runs` | Execution history, status, errors, duration. | +| `agent_logs` | Execution traces. | +| `task_dependencies` | Task dependency edges. | +| `audit_logs` | Governance trail. Coverage is partial and should be expanded. | +| `task_feedback` | Like/dislike feedback for future optimization. | +| `worker_heartbeats` | Background worker status and processing counters. | + +### Status Values + +Projects: + +```text +active, archived, completed +``` + +Tasks: + +```text +todo, queued, in_progress, awaiting_approval, done, failed, cancelled +``` + +Task runs: + +```text +queued, running, completed, failed, cancelled +``` + +Completed projects are locked by frontend controls and backend mutation checks. Reports remain available. + +## 3. Backend Contracts + +### Task Execution + +`POST /tasks/{task_id}/run` + +Optional query: + +```text +use_queue=true +``` + +1. Load task and assigned agent. +2. Reject execution if the parent project is completed. +3. If `use_queue=true` or `TASK_EXECUTION_MODE=queue`, set task to `queued` for worker execution. +4. Otherwise set task to `in_progress` and execute through `AgentRunnerService`. +5. Write `task_runs`, `agent_logs`, and task output. +6. Set task to `awaiting_approval` or `failed`. + +### Task Review + +```text +POST /tasks/{task_id}/approve +POST /tasks/{task_id}/reject +POST /tasks/project/{project_id}/approve-all +``` + +Approval runs output quality checks before moving a task to `done`. Rejection moves the task back to `todo`. These mutations are blocked when the project is completed. + +### Project Orchestration + +`POST /orchestrator/projects/{project_id}/run` + +Runs `todo` and `failed` tasks in priority order and assigns available agents when needed. If the project has no tasks, it can decompose the project into tasks. Completed projects are not mutable and cannot be orchestrated again. + +Queue mode: + +- `TASK_EXECUTION_MODE=queue`, or +- `POST /orchestrator/projects/{project_id}/run?use_queue=true` + +In queue mode, runnable tasks are assigned and moved to `queued` for `backend/worker.py`. + +### Reports + +```text +GET /orchestrator/projects/{project_id}/final-report?variant=full|brief|pessimistic +GET /orchestrator/projects/{project_id}/final-report.pdf?variant=full|brief|pessimistic +``` + +Reports are built from approved task output. Full report generation marks the project completed. + +### Queue Worker + +`backend/worker.py` polls `tasks.status = 'queued'` through `TaskQueueService`. + +Current state: + +- Worker scaffold exists. +- `queued` task status is supported by schema/migration. +- Task and project run endpoints can opt into queue mode. +- Workers claim tasks through `claim_next_queued_task`, an atomic Postgres function using `FOR UPDATE SKIP LOCKED`. +- Queue attempts, delayed retry time, and terminal failure text are stored on `tasks`. +- Worker heartbeat, active worker count, queue depth, delayed retry count, and stale lease metrics are exposed in Monitoring. + +## 4. Frontend + +### Primary Views + +- Dashboard: project cards, search, filters, status/progress sorting. +- New Project: wizard available in Guided and Expert modes. +- Project Detail: task management, guided workflow, reports, roadmap modal. +- Marketplace: agent template search and deploy. +- Agents: custom agent management. +- Debate: two-agent review flow. +- Monitoring: backend-first health summary with Supabase fallback. +- Voice Control: browser speech navigation/status. +- Spatial View: DAG-style task visualization. +- Settings: provider defaults, UI mode, user role management. + +### UI Modes + +Guided: + +- Simplified navigation and workflows. +- Project wizard steps: Basics, Context, Sources, Review. + +Expert: + +- Advanced tools and settings. +- Project wizard steps: Basics, Context, Sources, Access, Review. + +## 5. Security + +- Supabase Auth is used for authentication. +- Email/password is the visible login method in the current UI. +- Google/GitHub OAuth buttons are hidden. If OAuth is enabled in Supabase, follow `docs/AUTH_MODEL.md` before exposing OAuth buttons again. +- RLS policies protect project ownership, tasks, agents, templates, and profiles. +- Admin profile checks use a SECURITY DEFINER helper to avoid recursive RLS policies. +- Manager role is supported in profile constraints and admin tooling. + +## 6. Current Gaps + +- Audit log coverage is incomplete. +- Real-time logs are persisted, but true SSE/WebSocket streaming is not complete. +- Cost control exists only as provider token configuration, not persisted budget enforcement. +- Structured task schemas and `task_claims` evidence extraction exist for common task types. Extracted claims include normalized entity keys and claim hashes. Final reports include normalized evidence summaries, but they are not yet built exclusively from normalized evidence. +- Worker queue has atomic leasing, retry backoff, and heartbeat monitoring. Queue mode remains opt-in until it is made the default execution path. + +## 7. Validation + +Frontend: + +```powershell +cd frontend +npm run lint +npm run build +``` + +Backend syntax spot checks: + +```powershell +python -m py_compile backend\worker.py backend\services\task_queue.py +python -m py_compile backend\routers\agent_runner.py backend\routers\orchestrator.py +``` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..faef31a4357c48d6e4c55e84c8be8e3bc9055e20 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.7.0 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..c8e518e9cd2c66b8047b30961293c2d56158551f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,19 @@ +# Supabase Configuration +SUPABASE_URL=https://your-project-id.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + +# AI Provider Keys +OPENAI_API_KEY=your-openai-key +GROQ_API_KEY=your-groq-key +GEMINI_API_KEY=your-gemini-key +ANTHROPIC_API_KEY=your-anthropic-key +TAVILY_API_KEY=your-tavily-key + +# App Settings +PORT=8000 +ALLOWED_ORIGINS=http://localhost:5173,https://your-app.vercel.app +TASK_QUEUE_EMBEDDED_WORKER=true +OUTPUT_LANGUAGE=en + +# Error Tracking +SENTRY_DSN=your-sentry-dsn diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0afd6ed339be5637341ba3fce2a238d7e5ad0bd3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,32 @@ +# Build stage for backend +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Install Playwright browsers and their dependencies +RUN playwright install --with-deps chromium + +# Copy project +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/agents/agent_factory.py b/backend/agents/agent_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..529dc54e970bd314c9336a049841feb977b30b4e --- /dev/null +++ b/backend/agents/agent_factory.py @@ -0,0 +1,45 @@ +from typing import Dict, Type +from .base import BaseAgent +from .openai_agent import OpenAIAgent +from .amd_agent import AMDAgent +from .groq_agent import GroqAgent +from .gemini_agent import GeminiAgent +from .local_agent import LocalAgent +from .digitalocean_agent import DigitalOceanAgent +from services.config import settings + +# Map of providers to their respective classes +PROVIDER_MAP: Dict[str, Type[BaseAgent]] = { + "openai": OpenAIAgent, + "amd": AMDAgent, + "groq": GroqAgent, + "gemini": GeminiAgent, + "local": LocalAgent, + "ollama": LocalAgent, + "digitalocean": DigitalOceanAgent +} + +class AgentFactory: + @staticmethod + def get_agent(provider: str, name: str, role: str, model: str, system_prompt: str = None) -> BaseAgent: + """ + Instantiates the appropriate agent based on the provider string. + Includes a fallback to Groq if OpenAI is requested but no key is provided. + """ + provider = provider.lower() + + # Fallback Logic: OpenAI -> AMD -> Groq + if provider == "openai" and not settings.OPENAI_API_KEY: + if settings.AMD_API_KEY: + provider = "amd" + model = "llama-3.3-70b-instruct" + elif settings.GROQ_API_KEY: + provider = "groq" + model = "llama-3.3-70b-versatile" + + agent_class = PROVIDER_MAP.get(provider) + + if not agent_class: + raise ValueError(f"Unsupported agent provider: {provider}") + + return agent_class(name=name, role=role, model=model, system_prompt=system_prompt) diff --git a/backend/agents/amd_agent.py b/backend/agents/amd_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..20d4c4e5ced1fadccb1872cbfdb015550c954f60 --- /dev/null +++ b/backend/agents/amd_agent.py @@ -0,0 +1,42 @@ +from .base import BaseAgent +from typing import Dict, Any, List +import openai +from services.config import settings, config_service + +class AMDAgent(BaseAgent): + """ + Agent implementation for AMD Inference (inference.do-ai.run). + Compatible with OpenAI's API format. + """ + def __init__(self, name: str, role: str, model: str = "gpt-4o", system_prompt: str = None): + super().__init__(name, role, model, system_prompt) + + self.provider_config = config_service.get_provider_config("amd") + api_key = self.provider_config.get("api_key") or settings.AMD_API_KEY + + self.client = None + if api_key: + self.client = openai.AsyncOpenAI( + api_key=api_key, + base_url=self.provider_config.get("base_url", "https://inference.do-ai.run/v1") + ) + self.temperature = self.provider_config.get("temperature", 0.7) + self.max_tokens = self.provider_config.get("max_tokens", 4096) + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + if not self.client: + return { + "agent_name": self.name, + "provider": "amd", + "raw_output": "Error: AMD API Key not configured.", + "data": {"error": "Missing credentials"} + } + return await self._run_openai_compatible( + provider="amd", + create_fn=self.client.chat.completions.create, + task_description=task_description, + context=context, + use_tools=use_tools, + extra_context=extra_context, + response_format={"type": "json_object"} + ) diff --git a/backend/agents/base.py b/backend/agents/base.py new file mode 100644 index 0000000000000000000000000000000000000000..b3e20eebbf5187cc812e9ef7f52a671d4ccbd433 --- /dev/null +++ b/backend/agents/base.py @@ -0,0 +1,179 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any, List, Optional +import json + +class BaseAgent(ABC): + def __init__(self, name: str, role: str, model: str, system_prompt: Optional[str] = None): + self.name = name + self.role = role + self.model = model + self.system_prompt = system_prompt or f"You are {name}, acting as a {role}." + + @abstractmethod + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + """ + Executes a task given its description and previous context. + Returns a dictionary containing the output data. + """ + pass + + def _format_context(self, context: List[Dict[str, Any]]) -> str: + """Helper to format previous task outputs for the current agent.""" + if not context: + return "No previous context available." + + formatted = "Previous tasks context:\n" + for item in context: + formatted += f"- Task: {item.get('title')}\n Output: {json.dumps(item.get('output_data', {}))}\n" + return formatted + + def _build_json_prompt(self, task_description: str, context: List[Dict[str, Any]], extra_context: str = "") -> str: + return f""" +Task: {task_description} + +{self._format_context(context)} + +{extra_context} + +Please provide your output as a JSON object. +""" + + def _build_chat_messages(self, task_description: str, context: List[Dict[str, Any]], extra_context: str = "") -> List[Dict[str, Any]]: + return [ + {"role": "system", "content": self.system_prompt}, + {"role": "user", "content": self._build_json_prompt(task_description, context, extra_context)} + ] + + def _parse_json_output(self, content: str) -> Any: + """Parse strict JSON first, then tolerate fenced or prefixed JSON.""" + if not content: + return {} + + try: + return json.loads(content) + except json.JSONDecodeError: + pass + + try: + if "```json" in content: + clean = content.split("```json", 1)[1].split("```", 1)[0].strip() + elif "```" in content: + clean = content.split("```", 1)[1].split("```", 1)[0].strip() + else: + object_start, array_start = content.find("{"), content.find("[") + starts = [index for index in (object_start, array_start) if index != -1] + start = min(starts) if starts else -1 + if start == array_start: + end = content.rfind("]") + else: + end = content.rfind("}") + clean = content[start:end + 1] if start != -1 and end != -1 else content + return json.loads(clean) + except Exception: + return {"raw_text": content} + + def _parse_tool_arguments(self, arguments: str | None) -> Dict[str, Any]: + parsed = self._parse_json_output(arguments or "{}") + return parsed if isinstance(parsed, dict) else {} + + async def _append_tool_results(self, messages: List[Dict[str, Any]], tool_calls: Any, tool_registry: Any) -> None: + for tool_call in tool_calls or []: + tool_name = tool_call.function.name + tool_args = self._parse_tool_arguments(tool_call.function.arguments) + tool_result = await tool_registry.call_tool(tool_name, tool_args) + + messages.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": tool_name, + "content": str(tool_result), + }) + + async def _run_openai_compatible( + self, + provider: str, + create_fn, + task_description: str, + context: List[Dict[str, Any]], + use_tools: bool = False, + extra_context: str = "", + **extra_kwargs + ) -> Dict[str, Any]: + """ + Unified runner for OpenAI-compatible APIs (OpenAI, Groq, etc.) + """ + from tools.registry import tool_registry + + messages = self._build_chat_messages(task_description, context, extra_context) + + is_reasoning_model = "gpt-oss-" in self.model or self.model.startswith("o1-") or self.model.startswith("o3-") + + kwargs = { + "model": self.model, + "messages": messages, + **extra_kwargs + } + + # Handle temperature/max_tokens based on model type + if is_reasoning_model: + # Reasoning models prefer temperature 1.0 or none + kwargs["temperature"] = extra_kwargs.get("temperature", 1.0) + # Use max_completion_tokens if provided, otherwise default to max_tokens logic but renamed + if "max_completion_tokens" not in kwargs: + kwargs["max_completion_tokens"] = getattr(self, "max_tokens", 4096) + # Standard max_tokens is often forbidden in reasoning models + kwargs.pop("max_tokens", None) + else: + kwargs["temperature"] = getattr(self, "temperature", 0.7) + kwargs["max_tokens"] = getattr(self, "max_tokens", 4096) + + if use_tools: + # Note: Many reasoning models don't support tools yet, but we'll include if requested + kwargs["tools"] = tool_registry.get_tool_definitions() + kwargs["tool_choice"] = "auto" + + response = await create_fn(**kwargs) + message = response.choices[0].message + usage = getattr(response, "usage", None) + + if message.tool_calls: + messages.append(message) + await self._append_tool_results(messages, message.tool_calls, tool_registry) + + # Second call after tool execution + # Remove tools from second call to force a final answer + kwargs.pop("tools", None) + kwargs.pop("tool_choice", None) + + final_response = await create_fn(**kwargs) + final_usage = getattr(final_response, "usage", None) + if usage and final_usage: + usage.prompt_tokens += final_usage.prompt_tokens + usage.completion_tokens += final_usage.completion_tokens + usage.total_tokens += final_usage.total_tokens + elif final_usage: + usage = final_usage + + content = final_response.choices[0].message.content + else: + content = message.content + + usage_dict = None + if usage: + usage_dict = { + "prompt_tokens": getattr(usage, "prompt_tokens", 0), + "completion_tokens": getattr(usage, "completion_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0) + } + + return self._result(provider, content or "", usage=usage_dict) + + def _result(self, provider: str, content: str, usage: Optional[Dict[str, int]] = None) -> Dict[str, Any]: + return { + "agent_name": self.name, + "provider": provider, + "model": self.model, + "raw_output": content, + "usage": usage, + "data": self._parse_json_output(content) + } diff --git a/backend/agents/digitalocean_agent.py b/backend/agents/digitalocean_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..b12a6b8feb21ea0cd09260d79ccc9c0d9b14bdec --- /dev/null +++ b/backend/agents/digitalocean_agent.py @@ -0,0 +1,62 @@ +from .base import BaseAgent +from typing import Dict, Any, List +import openai +from services.config import settings, config_service + +class DigitalOceanAgent(BaseAgent): + """ + Agent provider using DigitalOcean's Gradient Inference API. + Supports both Serverless Inference and dedicated Agent Inference endpoints. + """ + def __init__(self, name: str, role: str, model: str = "llama-3.3-70b-instruct", system_prompt: str = None): + super().__init__(name, role, model, system_prompt) + + # Load dynamic config + self.provider_config = config_service.get_provider_config("digitalocean") + + # Priority: Agent Access Key -> Inference Key -> AMD Key -> DO Token + api_key = ( + self.provider_config.get("agent_access_key") or + settings.DO_AGENT_ACCESS_KEY or + self.provider_config.get("api_key") or + settings.DO_INFERENCE_KEY or + settings.AMD_API_KEY or + settings.DO_API_TOKEN + ) + + # Priority: Agent Endpoint -> Default Serverless Endpoint + base_url = ( + self.provider_config.get("base_url") or + settings.DO_AGENT_ENDPOINT or + "https://inference.do-ai.run/v1" + ) + + # Ensure base_url has the correct suffix if it's a raw agent URL + if ".agents.do-ai.run" in base_url and not base_url.endswith("/v1"): + base_url = f"{base_url.rstrip('/')}/v1" + elif "api.digitalocean.com" not in base_url and "do-ai.run" not in base_url: + # Fallback logic for potentially missing /v1 in custom domains + if not base_url.endswith("/v1"): + base_url = f"{base_url.rstrip('/')}/v1" + + self.client = openai.AsyncOpenAI( + api_key=api_key, + base_url=base_url + ) + self.is_agent_endpoint = "agents.do-ai.run" in base_url or settings.DO_AGENT_ENDPOINT is not None + self.temperature = self.provider_config.get("temperature", 0.7) + self.max_tokens = self.provider_config.get("max_tokens", 4096) + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + # DigitalOcean Agent Inference requires ?agent=true + extra_query = {"agent": "true"} if self.is_agent_endpoint else {} + + return await self._run_openai_compatible( + provider="digitalocean", + create_fn=self.client.chat.completions.create, + task_description=task_description, + context=context, + use_tools=use_tools, + extra_context=extra_context, + extra_query=extra_query + ) diff --git a/backend/agents/gemini_agent.py b/backend/agents/gemini_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..8e31ced4c45277abac12a09edf57e11c601d7268 --- /dev/null +++ b/backend/agents/gemini_agent.py @@ -0,0 +1,37 @@ +from .base import BaseAgent +from typing import Dict, Any, List +from google import genai +from services.config import settings, config_service + +class GeminiAgent(BaseAgent): + """ + Agent implementation for Google Gemini using the new google-genai SDK. + """ + def __init__(self, name: str, role: str, model: str = "gemini-2.0-flash", system_prompt: str = None): + super().__init__(name, role, model, system_prompt) + + # Load dynamic config + self.provider_config = config_service.get_provider_config("gemini") + api_key = self.provider_config.get("api_key") or settings.GEMINI_API_KEY + + self.client = genai.Client(api_key=api_key) + self.temperature = self.provider_config.get("temperature", 0.7) + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + full_prompt = f""" +System Instruction: {self.system_prompt} + +{self._build_json_prompt(task_description, context, extra_context)} +""" + + # Gemini 2.0 Flash is very fast. + response = await self.client.aio.models.generate( + model=self.model, + contents=full_prompt, + config={ + "temperature": self.temperature, + "response_mime_type": "application/json", + } + ) + + return self._result("gemini", response.text or "") diff --git a/backend/agents/groq_agent.py b/backend/agents/groq_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..49a7231e8a8a32fae3844c7eb2f05ac0d88c382a --- /dev/null +++ b/backend/agents/groq_agent.py @@ -0,0 +1,107 @@ +import logging +from .base import BaseAgent +from typing import Dict, Any, List +import groq +import json +from services.config import settings, config_service +from tools.registry import tool_registry + +logger = logging.getLogger("uvicorn") + +GROQ_ROTATION_POOL = [ + "llama-3.3-70b-versatile", + "openai/gpt-oss-120b", + "meta-llama/llama-4-scout-17b-16e-instruct", + "qwen/qwen3-32b", + "openai/gpt-oss-20b", + "groq/compound", + "llama-3.1-8b-instant" +] + +class GroqAgent(BaseAgent): + """ + Agent implementation for Groq with automatic model rotation for rate limits. + """ + def __init__(self, name: str, role: str, model: str = "llama-3.3-70b-versatile", system_prompt: str = None): + # Auto-migrate decommissioned models + if "llama-3.1-70b" in model or "llama3-70b-8192" in model: + model = "llama-3.3-70b-versatile" + + super().__init__(name, role, model, system_prompt) + + # Load dynamic config + self.provider_config = config_service.get_provider_config("groq") + api_key = self.provider_config.get("api_key") or settings.GROQ_API_KEY + + self.client = None + if api_key: + self.client = groq.AsyncGroq(api_key=api_key) + self.temperature = self.provider_config.get("temperature", 0.7) + self.max_tokens = self.provider_config.get("max_tokens", 4096) + self.reasoning_effort = self.provider_config.get("reasoning_effort", "medium") + + def _format_context(self, context: List[Dict[str, Any]]) -> str: + """Extremely aggressive truncation for Groq TPM limits.""" + if not context: + return "No previous context available." + + # Only take the last 3 tasks to save tokens + recent_context = context[-3:] + + formatted = "Previous tasks context (EXTREMELY TRUNCATED for Groq):\n" + for item in recent_context: + output_raw = json.dumps(item.get('output_data', {})) + # 800 chars is roughly 200 tokens. + if len(output_raw) > 800: + output_raw = output_raw[:800] + "... [TRUNCATED]" + + formatted += f"- Task: {item.get('title')}\n Output: {output_raw}\n" + return formatted + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + # Very limited semantic context + if len(extra_context) > 1000: + extra_context = extra_context[:1000] + "... [TRUNCATED]" + + try: + return await self._execute_run(task_description, context, use_tools, extra_context) + except groq.RateLimitError as e: + logger.warning(f"Rate limit reached for {self.model} (429). Attempting model rotation...") + + # Find current model index in pool + try: + current_idx = GROQ_ROTATION_POOL.index(self.model) + except ValueError: + current_idx = -1 + + # Try the next model in the pool + next_idx = (current_idx + 1) % len(GROQ_ROTATION_POOL) + fallback_model = GROQ_ROTATION_POOL[next_idx] + + logger.info(f"Rotating from {self.model} to {fallback_model}") + self.model = fallback_model + + # Retry once with fallback model + return await self._execute_run(task_description, context, use_tools, extra_context) + + async def _execute_run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + if not self.client: + return { + "agent_name": self.name, + "provider": "groq", + "raw_output": "Error: Groq API Key not configured.", + "data": {"error": "Missing credentials"} + } + extra_kwargs = {} + if "gpt-oss-" in self.model: + extra_kwargs["reasoning_effort"] = self.reasoning_effort + + return await self._run_openai_compatible( + provider="groq", + create_fn=self.client.chat.completions.create, + task_description=task_description, + context=context, + use_tools=use_tools, + extra_context=extra_context, + **extra_kwargs + ) diff --git a/backend/agents/local_agent.py b/backend/agents/local_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..008e5fb63ad5715ba21d4d90e760013000ae0076 --- /dev/null +++ b/backend/agents/local_agent.py @@ -0,0 +1,48 @@ +from .base import BaseAgent +from typing import Dict, Any, List +import httpx +from services.config import config_service + +class LocalAgent(BaseAgent): + """ + Agent implementation for Local LLMs (Ollama). + """ + def __init__(self, name: str, role: str, model: str = "llama3.1:8b", system_prompt: str = None): + super().__init__(name, role, model, system_prompt) + + # Load dynamic config + self.provider_config = config_service.get_provider_config("ollama") + self.base_url = self.provider_config.get("base_url", "http://localhost:11434") + self.temperature = self.provider_config.get("temperature", 0.7) + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + full_prompt = f""" +System Instructions: {self.system_prompt} + +{self._build_json_prompt(task_description, context, extra_context)} +""" + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.post( + f"{self.base_url}/api/generate", + json={ + "model": self.model, + "prompt": full_prompt, + "stream": False, + "format": "json", + "options": { + "temperature": self.temperature + } + } + ) + response.raise_for_status() + result = response.json() + return self._result("local", result.get("response", "{}")) + except Exception as e: + return { + "agent_name": self.name, + "provider": "local", + "status": "error", + "error": f"Ollama connection failed: {str(e)}" + } diff --git a/backend/agents/openai_agent.py b/backend/agents/openai_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..7e872abe1f456a3a905c20005b1a16a2965f1d8a --- /dev/null +++ b/backend/agents/openai_agent.py @@ -0,0 +1,37 @@ +from .base import BaseAgent +from typing import Dict, Any, List +import openai +from services.config import settings, config_service +from tools.registry import tool_registry + +class OpenAIAgent(BaseAgent): + def __init__(self, name: str, role: str, model: str = "gpt-4o", system_prompt: str = None): + super().__init__(name, role, model, system_prompt) + + # Load dynamic config + self.provider_config = config_service.get_provider_config("openai") + api_key = self.provider_config.get("api_key") or settings.OPENAI_API_KEY + + self.client = None + if api_key: + self.client = openai.AsyncOpenAI(api_key=api_key) + self.temperature = self.provider_config.get("temperature", 0.7) + self.max_tokens = self.provider_config.get("max_tokens", 4096) + + async def run(self, task_description: str, context: List[Dict[str, Any]], use_tools: bool = False, extra_context: str = "") -> Dict[str, Any]: + if not self.client: + return { + "agent_name": self.name, + "provider": "openai", + "raw_output": "Error: OpenAI API Key not configured.", + "data": {"error": "Missing credentials"} + } + return await self._run_openai_compatible( + provider="openai", + create_fn=self.client.chat.completions.create, + task_description=task_description, + context=context, + use_tools=use_tools, + extra_context=extra_context, + response_format={"type": "json_object"} + ) diff --git a/backend/agents_debug.json b/backend/agents_debug.json new file mode 100644 index 0000000000000000000000000000000000000000..25bd52fe9a7facac6c9a603087bdc24abbf85788 --- /dev/null +++ b/backend/agents_debug.json @@ -0,0 +1 @@ +[{"id": "297ef087-89af-4e3b-8d80-c5c5c7499e3b", "name": "GPT-4o", "role": "General Intelligence", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a highly capable AI assistant.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "64aebf0c-625a-4c6f-895d-a36274c4f9fd", "name": "AMD-4o", "role": "Performance Specialist", "api_provider": "amd", "model": "gpt-4o", "system_prompt": "You are a high-performance agent running on AMD infrastructure.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "f7cc5a82-1e7d-4f21-9855-922fb82cd6f9", "name": "Llama-3-70B", "role": "Fast Logic", "api_provider": "groq", "model": "llama3-70b-8192", "system_prompt": "You are a fast and efficient reasoning agent.", "created_at": "2026-05-04T14:32:03.072888+00:00", "updated_at": "2026-05-04T14:32:03.072888+00:00", "user_id": null}, {"id": "1d988b3c-38c1-4132-85e1-82e7a4bc4f8a", "name": "Growth Hacker", "role": "Marketing Expert", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a Growth Hacker focused on low-cost, high-impact strategies.", "created_at": "2026-05-04T15:50:52.628388+00:00", "updated_at": "2026-05-04T15:50:52.628388+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "7b056c90-f4a6-4629-81b1-f4316b76d091", "name": "Growth Hacker", "role": "Marketing Expert", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You are a Growth Hacker focused on low-cost, high-impact strategies.", "created_at": "2026-05-04T16:22:10.993123+00:00", "updated_at": "2026-05-04T16:22:10.993123+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "138d7d29-b2c0-4ffb-b89f-1a0965dca6b6", "name": "Planner", "role": "Project Planner", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You decompose goals into clear, ordered implementation tasks.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "edfc99cf-a70c-42e0-a2f6-4508bb6aac33", "name": "Builder", "role": "Implementation Agent", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You implement practical, production-oriented solutions with concise output.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}, {"id": "5ef6c6f3-fc4a-40ba-abe8-7630fefcece2", "name": "Reviewer", "role": "Quality Reviewer", "api_provider": "openai", "model": "gpt-4o", "system_prompt": "You review outputs for correctness, security, completeness, and missing tests.", "created_at": "2026-05-04T18:06:05.280562+00:00", "updated_at": "2026-05-04T18:06:05.280562+00:00", "user_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578"}] \ No newline at end of file diff --git a/backend/api/index.py b/backend/api/index.py new file mode 100644 index 0000000000000000000000000000000000000000..bbbc533e8f7e4542abbb527bfbbf2c4b195cffb9 --- /dev/null +++ b/backend/api/index.py @@ -0,0 +1 @@ +from main import app diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..6e600cfd2dc2618d62feb2913d5d4385a02f1607 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,199 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, Response, JSONResponse +import asyncio +import logging +import os +import json +from pathlib import Path +from dotenv import load_dotenv +import sentry_sdk +from services.orchestrator_service import orchestrator_service +from services.infrastructure_service import infrastructure_service +from services.config import settings +from worker import AubmWorker + + +def _load_app_version() -> str: + version_file = Path(__file__).resolve().parent.parent / "VERSION" + if version_file.exists(): + value = version_file.read_text(encoding="utf-8").strip() + if value: + return value + return os.getenv("APP_VERSION", "0.7.0") + + +# Load environment variables +load_dotenv() + +# Silence noisy libraries +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) +logging.getLogger("supabase").setLevel(logging.WARNING) +logging.getLogger("postgrest").setLevel(logging.WARNING) + +FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist" +APP_VERSION = _load_app_version() +logger = logging.getLogger("aubm.api") +embedded_worker: AubmWorker | None = None +embedded_worker_task: asyncio.Task | None = None + +# Sentry Initialization +SENTRY_DSN = os.getenv("SENTRY_DSN") +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + ) + +app = FastAPI( + title="Aubm API", + description="Enterprise-Grade AI Agent Orchestration & Collaboration Platform", + version=APP_VERSION +) + +# CORS Configuration +allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins if allowed_origins != ["*"] else ["*"], + allow_origin_regex=os.getenv("ALLOWED_ORIGIN_REGEX"), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def _log_embedded_worker_result(task: asyncio.Task) -> None: + if task.cancelled(): + return + + exc = task.exception() + if exc: + logger.error( + "Embedded worker stopped unexpectedly", + exc_info=(type(exc), exc, exc.__traceback__), + ) + + +@app.on_event("startup") +async def start_embedded_worker() -> None: + global embedded_worker, embedded_worker_task + + if settings.TASK_EXECUTION_MODE != "queue" or not settings.TASK_QUEUE_EMBEDDED_WORKER: + return + + if embedded_worker_task and not embedded_worker_task.done(): + return + + embedded_worker = AubmWorker() + embedded_worker_task = asyncio.create_task(embedded_worker.start()) + embedded_worker_task.add_done_callback(_log_embedded_worker_result) + logger.info("Embedded task worker started: %s", embedded_worker.worker_id) + + +@app.on_event("shutdown") +async def stop_embedded_worker() -> None: + global embedded_worker, embedded_worker_task + + if not embedded_worker or not embedded_worker_task: + return + + embedded_worker.stop() + try: + await asyncio.wait_for(embedded_worker_task, timeout=10) + await embedded_worker.heartbeat("stopping") + except asyncio.TimeoutError: + embedded_worker_task.cancel() + logger.warning("Embedded task worker did not stop before timeout") + finally: + embedded_worker = None + embedded_worker_task = None + + +@app.get("/") +async def root(): + index_path = FRONTEND_DIST / "index.html" + if index_path.exists(): + return FileResponse(index_path) + + return { + "status": "online", + "message": "Aubm API is operational", + "version": APP_VERSION + } + +# Placeholder for routers +from routers import orchestrator, monitoring, agent_runner, generator + +app.include_router(agent_runner.router, prefix="/api/tasks", tags=["Tasks"]) +app.include_router(orchestrator.router, prefix="/api/orchestrator", tags=["orchestrator"]) +app.include_router(generator.router, prefix="/api/generator", tags=["generator"]) +app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"]) + +@app.get("/runtime-config.js", include_in_schema=False) +async def runtime_config(): + config = { + "apiUrl": os.getenv("VITE_API_URL", ""), + "supabaseUrl": os.getenv("VITE_SUPABASE_URL", os.getenv("SUPABASE_URL", "")), + "supabaseAnonKey": os.getenv("VITE_SUPABASE_ANON_KEY", os.getenv("SUPABASE_ANON_KEY", "")), + "sentryDsn": os.getenv("VITE_SENTRY_DSN", os.getenv("SENTRY_DSN", "")), + "appVersion": APP_VERSION, + } + return Response( + content=f"window.__AUBM_CONFIG__ = {json.dumps(config)};", + media_type="application/javascript", + ) + +@app.get("/{path:path}", include_in_schema=False) +async def serve_frontend(path: str): + if not FRONTEND_DIST.exists(): + return await root() + + requested_path = FRONTEND_DIST / path + if requested_path.is_file(): + return FileResponse(requested_path) + + + + # For SPA routing, serve index.html for all other paths, + # but NOT for paths starting with api/ (which should have been caught by routers) + if path.startswith("api/"): + return JSONResponse(status_code=404, content={"detail": f"API route not found: /{path}"}) + + index_path = FRONTEND_DIST / "index.html" + if index_path.exists(): + return FileResponse(index_path) + + return await root() + +# --- Infrastructure Management --- + +@app.post("/infrastructure/nodes/provision") +async def provision_node(name: str = "aubm-inference-node", size: str = "s-4vcpu-8gb-amd"): + """Creates a new inference node on DigitalOcean.""" + node = await infrastructure_service.create_inference_node(name, size) + if not node: + raise HTTPException(status_code=500, detail="Failed to initiate node provisioning.") + return node + +@app.get("/infrastructure/nodes/{droplet_id}/ip") +async def get_node_ip(droplet_id: int): + """Wait and return the public IP of a node.""" + ip = await infrastructure_service.wait_for_ip(droplet_id) + if not ip: + raise HTTPException(status_code=404, detail="IP not assigned or timed out.") + return {"ip": ip} + +@app.delete("/infrastructure/nodes/{droplet_id}") +async def terminate_node(droplet_id: int): + """Destroy an inference node.""" + success = await infrastructure_service.terminate_node(droplet_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to terminate node.") + return {"status": "termination_requested"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(settings.PORT)) diff --git a/backend/project_debug.json b/backend/project_debug.json new file mode 100644 index 0000000000000000000000000000000000000000..a4216b8a2c9066f0de8302f3acef633c030839fe --- /dev/null +++ b/backend/project_debug.json @@ -0,0 +1 @@ +{"id": "53f39562-09aa-447b-b251-3844e1415d4c", "name": "Commercial analysis", "description": "I want to evaluate differents applications similar to this app", "context": "search in internet", "owner_id": "483025be-ca4b-4de3-aa80-9eb39dbd3578", "status": "active", "is_public": true, "created_at": "2026-05-04T16:38:03.872534+00:00", "updated_at": "2026-05-04T16:38:03.872534+00:00", "team_id": null} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d272a518ec8a407910636cf227dbedd58d0cd306 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +fastapi +uvicorn[standard] +supabase +openai +groq +google-genai +playwright +folium +python-dotenv +pydantic +pydantic-settings +httpx +jinja2 +python-multipart +reportlab +pandas +openpyxl +psutil +sentry-sdk[fastapi] diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..873f7bbbe945dde0325ebbbd742d46dd672befa0 --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/backend/routers/agent_runner.py b/backend/routers/agent_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..05df9ffd31dc8e0eed2ff325e4005ebe8ce2ebd3 --- /dev/null +++ b/backend/routers/agent_runner.py @@ -0,0 +1,483 @@ +from fastapi import APIRouter, HTTPException, BackgroundTasks, Request +from fastapi.responses import StreamingResponse +from services.supabase_service import supabase +from services.agent_runner_service import AgentRunnerService +from services.config import settings +from services.audit_service import audit_service +from services.output_quality import report_text_from_output +from services.task_queue import TaskQueueService +from services.memory_service import memory_service +from services.project_service import project_service +from services.utils import log_async_task_result +import asyncio +import json +import logging + +router = APIRouter() +logger = logging.getLogger("uvicorn") + + +def _assert_task_quality(task: dict): + output_data = task.get("output_data") or {} + if not isinstance(output_data, dict): + raise HTTPException(status_code=400, detail="Task output is missing or malformed.") + if output_data.get("error"): + raise HTTPException(status_code=400, detail=f"Task execution failed: {output_data['error']}") + rendered = report_text_from_output(output_data).strip() + if not rendered or rendered in ("{}", "[]"): + raise HTTPException(status_code=400, detail="Task has no usable output to approve.") + quality_review = output_data.get("quality_review") + if not quality_review: + raise HTTPException(status_code=400, detail="Task output is missing quality validation.") + if quality_review.get("approved"): + return + reasons = quality_review.get("fail_reasons") or ["Task output failed quality validation."] + raise HTTPException(status_code=400, detail=f"Task output failed quality review: {'; '.join(reasons)}") + + +def _assert_task_project_is_mutable(task: dict): + project_id = task.get("project_id") + if project_id: + project_service.ensure_project_is_mutable(project_id) + +def update_task_status(task_id: str, status: str): + task_res = supabase.table("tasks").select("project_id").eq("id", task_id).single().execute() + if not task_res.data: + raise HTTPException(status_code=404, detail="Task not found") + _assert_task_project_is_mutable(task_res.data) + + result = ( + supabase.table("tasks") + .update({"status": status}) + .eq("id", task_id) + .execute() + ) + if not result.data: + raise HTTPException(status_code=404, detail="Task not found or status was not updated") + + task_data = result.data[0] + + project_id = task_data.get("project_id") + if project_id: + task_result = ( + supabase.table("tasks") + .select("id,status") + .eq("project_id", project_id) + .execute() + ) + tasks = task_result.data or [] + if status == "done" and tasks and all(t.get("status") == "done" for t in tasks): + supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute() + elif status != "done": + supabase.table("projects").update({"status": "active"}).eq("id", project_id).execute() + + return task_data + + +def _sse_event(event: str, data: dict, event_id: str | None = None) -> str: + lines = [] + if event_id: + lines.append(f"id: {event_id}") + lines.append(f"event: {event}") + payload = json.dumps(data, default=str) + for line in payload.splitlines() or ["{}"]: + lines.append(f"data: {line}") + return "\n".join(lines) + "\n\n" + + +def _project_task_ids(project_id: str) -> list[str]: + rows = ( + supabase.table("tasks") + .select("id") + .eq("project_id", project_id) + .execute() + .data + or [] + ) + return [row["id"] for row in rows if row.get("id")] + + +def _user_id_from_access_token(access_token: str | None) -> str: + if not access_token: + raise HTTPException(status_code=401, detail="Missing access token") + try: + auth_user = supabase.auth.get_user(access_token) + user = getattr(auth_user, "user", None) + user_id = getattr(user, "id", None) + if not user_id and isinstance(auth_user, dict): + user_id = auth_user.get("user", {}).get("id") + except Exception as exc: + logger.warning("Could not validate log stream access token: %s", exc) + raise HTTPException(status_code=401, detail="Invalid access token") from exc + if not user_id: + raise HTTPException(status_code=401, detail="Invalid access token") + return user_id + + +def _team_ids_for_user(user_id: str) -> list[str]: + try: + rows = ( + supabase.table("team_members") + .select("team_id") + .eq("user_id", user_id) + .execute() + .data + or [] + ) + except Exception as exc: + logger.warning("Team membership lookup unavailable for log stream: %s", exc) + return [] + return [row["team_id"] for row in rows if row.get("team_id")] + + +def _project_ids_for_user(user_id: str) -> list[str]: + project_ids: set[str] = set() + + owned = ( + supabase.table("projects") + .select("id") + .eq("owner_id", user_id) + .execute() + .data + or [] + ) + project_ids.update(row["id"] for row in owned if row.get("id")) + + public = ( + supabase.table("projects") + .select("id") + .eq("is_public", True) + .execute() + .data + or [] + ) + project_ids.update(row["id"] for row in public if row.get("id")) + + team_ids = _team_ids_for_user(user_id) + if team_ids: + team_projects = ( + supabase.table("projects") + .select("id") + .in_("team_id", team_ids) + .execute() + .data + or [] + ) + project_ids.update(row["id"] for row in team_projects if row.get("id")) + + return list(project_ids) + + +def _can_view_project_for_user(project_id: str, user_id: str) -> bool: + if not project_id: + return False + if project_id in _project_ids_for_user(user_id): + return True + return False + + +def _authorized_task_ids(user_id: str, project_id: str | None = None, task_id: str | None = None) -> list[str]: + if task_id: + task = supabase.table("tasks").select("id,project_id").eq("id", task_id).single().execute().data + if not task or not _can_view_project_for_user(task.get("project_id"), user_id): + raise HTTPException(status_code=403, detail="Task logs are not visible to this user") + return [task_id] + + if project_id: + if not _can_view_project_for_user(project_id, user_id): + raise HTTPException(status_code=403, detail="Project logs are not visible to this user") + return _project_task_ids(project_id) + + project_ids = _project_ids_for_user(user_id) + if not project_ids: + return [] + rows = ( + supabase.table("tasks") + .select("id") + .in_("project_id", project_ids) + .execute() + .data + or [] + ) + return [row["id"] for row in rows if row.get("id")] + + +def _fetch_recent_logs( + limit: int = 50, + after_created_at: str | None = None, + *, + task_ids: list[str], +) -> list[dict]: + if not task_ids: + return [] + query = ( + supabase.table("agent_logs") + .select("id,task_id,run_id,action,content,metadata,created_at") + .order("created_at", desc=after_created_at is None) + .limit(limit) + .in_("task_id", task_ids) + ) + if after_created_at: + query = query.gt("created_at", after_created_at) + rows = query.execute().data or [] + return rows if after_created_at else list(reversed(rows)) + + +@router.get("/logs/stream") +async def stream_agent_logs( + request: Request, + limit: int = 50, + project_id: str | None = None, + task_id: str | None = None, + access_token: str | None = None, +): + """ + Streams agent log inserts as Server-Sent Events. + """ + if project_id and task_id: + raise HTTPException(status_code=400, detail="Use either project_id or task_id, not both.") + user_id = _user_id_from_access_token(access_token) + task_ids = _authorized_task_ids(user_id, project_id=project_id, task_id=task_id) + + async def event_generator(): + last_created_at = None + sent_ids: set[str] = set() + yield _sse_event("ready", { + "message": "Agent log stream connected", + "project_id": project_id, + "task_id": task_id, + "user_id": user_id, + }) + + while not await request.is_disconnected(): + try: + rows = _fetch_recent_logs( + limit=max(1, min(limit, 100)), + after_created_at=last_created_at, + task_ids=task_ids, + ) + for row in rows: + row_id = row.get("id") + if row_id in sent_ids: + continue + sent_ids.add(row_id) + if len(sent_ids) > 500: + sent_ids = set(list(sent_ids)[-250:]) + last_created_at = row.get("created_at") or last_created_at + yield _sse_event("log", row, row_id) + except Exception as exc: + logger.warning("Agent log SSE stream failed to fetch logs: %s", exc) + yield _sse_event("error", {"message": str(exc)}) + + yield ": keep-alive\n\n" + await asyncio.sleep(1) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/{task_id}/run") +async def run_task(task_id: str, background_tasks: BackgroundTasks, use_queue: bool | None = None): + """ + Triggers the execution of a specific task. + """ + # 1. Fetch task data + task_res = supabase.table("tasks").select("*, project:projects(*)").eq("id", task_id).single().execute() + if not task_res.data: + raise HTTPException(status_code=404, detail="Task not found") + + task = task_res.data + _assert_task_project_is_mutable(task) + + # 2. Check if agent is assigned + agent_id = task.get("assigned_agent_id") + if not agent_id: + raise HTTPException(status_code=400, detail="No agent assigned to this task") + + # 3. Fetch agent data + agent_res = supabase.table("agents").select("*").eq("id", agent_id).single().execute() + if not agent_res.data: + raise HTTPException(status_code=404, detail="Assigned agent not found") + + agent_data = agent_res.data + + should_queue = use_queue if use_queue is not None else False + if should_queue: + queued = await TaskQueueService.queue_task(task_id) + if not queued or not queued.data: + raise HTTPException(status_code=500, detail="Task could not be queued") + await audit_service.log_action( + user_id=task.get("project", {}).get("owner_id"), + action="task_queued", + agent_id=agent_id, + task_id=task_id, + metadata={"project_id": task.get("project_id"), "source": "task_run_endpoint"}, + ) + return {"message": "Task queued for worker execution", "task_id": task_id, "mode": "queue"} + + # 4. Update task status to in_progress + supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute() + await audit_service.log_action( + user_id=task.get("project", {}).get("owner_id"), + action="task_run_started", + agent_id=agent_id, + task_id=task_id, + metadata={"project_id": task.get("project_id"), "mode": "direct"}, + ) + + # 5. Run in background + runner_task = asyncio.create_task(AgentRunnerService.execute_agent_logic(task, agent_data)) + runner_task.add_done_callback(lambda current: log_async_task_result(current, f"run_task({task_id})")) + + return {"message": "Task execution started", "task_id": task_id} + +@router.patch("/{task_id}/output") +async def update_task_output(task_id: str, payload: dict): + """ + Updates the output_data of a task. Allows for manual human corrections. + """ + if "output_data" not in payload: + raise HTTPException(status_code=400, detail="Missing output_data in payload") + + # Verify task existence and project state + task_res = supabase.table("tasks").select("id, project_id").eq("id", task_id).single().execute() + if not task_res.data: + raise HTTPException(status_code=404, detail="Task not found") + _assert_task_project_is_mutable(task_res.data) + + result = supabase.table("tasks").update({ + "output_data": payload["output_data"] + }).eq("id", task_id).execute() + + if not result.data: + raise HTTPException(status_code=500, detail="Failed to update task output") + + await audit_service.log_action( + user_id=None, + action="task_output_manually_edited", + task_id=task_id, + metadata={"project_id": task_res.data["project_id"]} + ) + + return {"message": "Task output updated", "task": result.data[0]} + +@router.post("/{task_id}/approve") +async def approve_task(task_id: str, background_tasks: BackgroundTasks): + task_res = supabase.table("tasks").select("*").eq("id", task_id).single().execute() + if not task_res.data: + raise HTTPException(status_code=404, detail="Task not found") + _assert_task_project_is_mutable(task_res.data) + _assert_task_quality(task_res.data) + task = update_task_status(task_id, "done") + + # Index for Long-Term Memory + background_tasks.add_task(memory_service.index_task_output, task) + + await audit_service.log_action( + user_id=None, + action="task_approved", + agent_id=task.get("assigned_agent_id"), + task_id=task_id, + metadata={"project_id": task.get("project_id")}, + ) + return {"message": "Task approved", "task": task} + +@router.post("/{task_id}/reject") +async def reject_task(task_id: str, background_tasks: BackgroundTasks, feedback: str | None = None): + task = update_task_status(task_id, "todo") + + # Trigger Self-Optimization Loop + background_tasks.add_task( + memory_service.analyze_rejection, + task_id=task_id, + feedback=feedback + ) + + await audit_service.log_action( + user_id=None, + action="task_rejected", + agent_id=task.get("assigned_agent_id"), + task_id=task_id, + metadata={"project_id": task.get("project_id")}, + ) + return {"message": "Task rejected", "task": task} + +@router.post("/project/{project_id}/approve-all") +async def approve_all_tasks(project_id: str, background_tasks: BackgroundTasks): + """ + Approves all tasks in a project that are awaiting approval. + """ + project_service.ensure_project_is_mutable(project_id) + waiting_tasks = ( + supabase.table("tasks") + .select("*") + .eq("project_id", project_id) + .eq("status", "awaiting_approval") + .execute() + .data + or [] + ) + + blocked = [] + approvable_ids = [] + + for task in waiting_tasks: + try: + _assert_task_quality(task) + approvable_ids.append(task["id"]) + except HTTPException as exc: + blocked.append({ + "task_id": task["id"], + "title": task.get("title", "Untitled Task"), + "reason": exc.detail + }) + + # 1. Update tasks + result_data = [] + if approvable_ids: + result = ( + supabase.table("tasks") + .update({"status": "done"}) + .eq("project_id", project_id) + .in_("id", approvable_ids) + .execute() + ) + result_data = result.data or [] + + # Index all approved tasks for Long-Term Memory + for approved_task in result_data: + background_tasks.add_task(memory_service.index_task_output, approved_task) + + # 2. Check if all tasks in project are now done + task_result = ( + supabase.table("tasks") + .select("status") + .eq("project_id", project_id) + .execute() + ) + tasks = task_result.data or [] + if tasks and all(t.get("status") == "done" for t in tasks): + supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute() + + await audit_service.log_action( + user_id=None, + action="tasks_approved_bulk", + metadata={ + "project_id": project_id, + "approved_count": len(result_data), + "blocked_count": len(blocked), + }, + ) + + return { + "message": f"Approved {len(result_data)} tasks. {len(blocked)} tasks were blocked due to quality issues.", + "count": len(result_data), + "blocked": blocked + } diff --git a/backend/routers/generator.py b/backend/routers/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..e34704073df691e62b5bd70333d89e14a2955e7f --- /dev/null +++ b/backend/routers/generator.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, UploadFile, File, Form, HTTPException +from typing import List, Optional +import json +import logging +import groq +from services.supabase_service import supabase +from services.config import settings, config_service +from pydantic import BaseModel + +router = APIRouter() +logger = logging.getLogger("aubm.generator") + +def _parse_json_output(content: str): + """Robust JSON parsing from LLM output.""" + if not content: + return {} + try: + return json.loads(content) + except json.JSONDecodeError: + pass + try: + if "```json" in content: + clean = content.split("```json", 1)[1].split("```", 1)[0].strip() + elif "```" in content: + clean = content.split("```", 1)[1].split("```", 1)[0].strip() + else: + object_start = content.find("{") + end = content.rfind("}") + clean = content[object_start:end + 1] if object_start != -1 and end != -1 else content + return json.loads(clean) + except Exception: + return {"name": "Generation Failed", "description": content, "context": ""} + +@router.post("/generate-project") +async def generate_project( + prompt: str = Form(...), + files: List[UploadFile] = File(None) +): + """ + Generates a project structure from a natural language prompt and reference files. + """ + logger.info("Generating project structure for prompt: %s", prompt[:50]) + + # 1. Extract context from files + file_contexts = [] + if files: + for file in files: + content = await file.read() + try: + text = content.decode("utf-8") + file_contexts.append(f"File: {file.filename}\nContent:\n{text}") + except Exception as e: + logger.warning("Could not decode file %s: %s", file.filename, e) + + full_context = "\n\n".join(file_contexts) + + # 2. Prepare LLM prompt + system_prompt = """ + You are an expert Project Architect for the Aubm platform. + Your goal is to take a user prompt and reference documents to create a structured project definition. + + Return ONLY a valid JSON object with the following keys: + { + "name": "Short Professional Name", + "description": "High level summary", + "context": "Detailed constraints, objectives, and requirements extracted from docs.", + "sources": [{"kind": "note", "label": "Analysis Note", "content": "..."}] + } + """ + + user_message = f"User Prompt: {prompt}\n\nReference Context:\n{full_context}" + + try: + # 3. Call Groq + provider_config = config_service.get_provider_config("groq") + api_key = provider_config.get("api_key") or settings.GROQ_API_KEY + + if not api_key: + logger.error("GROQ_API_KEY is missing in settings and config") + raise HTTPException(status_code=500, detail="GROQ_API_KEY not configured") + + client = groq.AsyncGroq(api_key=api_key) + + # Use llama-3.3-70b-versatile to match GroqAgent.py + model_name = provider_config.get("default_model") or "llama-3.3-70b-versatile" + logger.info("Calling Groq with model: %s (Key: %s...)", model_name, api_key[:8] if api_key else "None") + + response = await client.chat.completions.create( + model=model_name, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message} + ], + temperature=0.3, + max_tokens=2048 + ) + + response_text = response.choices[0].message.content + logger.info("Groq raw response received (%d chars)", len(response_text) if response_text else 0) + data = _parse_json_output(response_text) + return data + + except Exception as e: + logger.exception("Project generation failed") + error_type = type(e).__name__ + error_msg = str(e) + if "401" in error_msg: + error_msg = "Invalid API Key - Please check your Groq Dashboard and .env" + raise HTTPException(status_code=500, detail=f"AI Error ({error_type}): {error_msg}") diff --git a/backend/routers/monitoring.py b/backend/routers/monitoring.py new file mode 100644 index 0000000000000000000000000000000000000000..bba4c26d319bc393ebed65f1084e9495a6386c3c --- /dev/null +++ b/backend/routers/monitoring.py @@ -0,0 +1,121 @@ +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter +from services.supabase_service import supabase + +router = APIRouter() + + +def _count_table(table_name: str) -> int: + response = supabase.table(table_name).select("id", count="exact").limit(1).execute() + return response.count or 0 + + +def _count_tasks_by_status(status: str) -> int: + return ( + supabase.table("tasks") + .select("id", count="exact") + .eq("status", status) + .limit(1) + .execute() + .count + or 0 + ) + + +@router.get("/summary") +async def monitoring_summary(): + """ + Lightweight operational summary for dashboards and uptime checks. + """ + checks = { + "api": "ok", + "database": "ok", + "workers": "checking", + } + + counts = { + "projects": 0, + "tasks": 0, + "agents": 0, + "task_runs": 0, + "failed_tasks": 0, + "pending_reviews": 0, + "queued_tasks": 0, + "in_progress_tasks": 0, + "stale_leases": 0, + "delayed_retries": 0, + "active_workers": 0, + } + + try: + counts["projects"] = _count_table("projects") + counts["tasks"] = _count_table("tasks") + counts["agents"] = _count_table("agents") + counts["task_runs"] = _count_table("task_runs") + counts["failed_tasks"] = _count_tasks_by_status("failed") + counts["pending_reviews"] = _count_tasks_by_status("awaiting_approval") + counts["queued_tasks"] = _count_tasks_by_status("queued") + counts["in_progress_tasks"] = _count_tasks_by_status("in_progress") + + now = datetime.now(timezone.utc) + counts["stale_leases"] = ( + supabase.table("tasks") + .select("id", count="exact") + .eq("status", "in_progress") + .lt("lease_expires_at", now.isoformat()) + .limit(1) + .execute() + .count + or 0 + ) + counts["delayed_retries"] = ( + supabase.table("tasks") + .select("id", count="exact") + .eq("status", "queued") + .gt("next_attempt_at", now.isoformat()) + .limit(1) + .execute() + .count + or 0 + ) + + try: + active_since = now - timedelta(minutes=2) + counts["active_workers"] = ( + supabase.table("worker_heartbeats") + .select("worker_id", count="exact") + .gte("last_seen_at", active_since.isoformat()) + .neq("status", "stopping") + .limit(1) + .execute() + .count + or 0 + ) + checks["workers"] = "ok" if counts["active_workers"] > 0 or counts["queued_tasks"] == 0 else "warning" + except Exception as exc: + checks["workers"] = "unavailable" + counts["active_workers"] = 0 + worker_error = str(exc) + else: + worker_error = None + except Exception as exc: + checks["database"] = "error" + return { + "status": "degraded", + "checks": checks, + "counts": counts, + "error": str(exc), + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + error = None + if worker_error: + error = f"Worker heartbeat table unavailable: {worker_error}" + + return { + "status": "ok" if checks["workers"] in ("ok", "unavailable") and counts["stale_leases"] == 0 else "degraded", + "checks": checks, + "counts": counts, + "error": error, + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/routers/orchestrator.py b/backend/routers/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..d2b385b751bea31088f57eaf2b55b99238d12e4d --- /dev/null +++ b/backend/routers/orchestrator.py @@ -0,0 +1,233 @@ +import asyncio +import logging +from fastapi import APIRouter, BackgroundTasks, HTTPException +from fastapi.responses import Response +from services.orchestrator_service import orchestrator_service +from services.supabase_service import supabase +from services.config import settings +from services.budget_service import budget_service +from services.evidence_service import evidence_service +from services.project_service import project_service +from services.utils import log_async_task_result +from pydantic import BaseModel +from io import BytesIO +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle +from reportlab.lib import colors +from reportlab.lib.units import inch +from xml.sax.saxutils import escape +import re + +router = APIRouter() +logger = logging.getLogger("uvicorn") + + + + +def _safe_filename(value: str) -> str: + return re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_").lower() or "report" + +def _pdf_text(value: str) -> str: + return escape(str(value)) + +def _report_body_without_execution_summary(content: str) -> list[str]: + lines: list[str] = [] + skipping = False + for raw_line in content.splitlines(): + if raw_line.startswith("## Execution Summary"): + skipping = True + continue + if skipping and raw_line.startswith("## "): + skipping = False + if not skipping: + lines.append(raw_line) + return lines + +def _report_pdf_bytes(title: str, content: str, charts: dict | None = None) -> bytes: + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + rightMargin=0.7 * inch, + leftMargin=0.7 * inch, + topMargin=0.7 * inch, + bottomMargin=0.7 * inch, + ) + styles = getSampleStyleSheet() + story = [Paragraph(_pdf_text(title), styles["Title"]), Spacer(1, 0.2 * inch)] + if charts: + story.append(Paragraph("Project Execution Summary", styles["Heading2"])) + story.append(Spacer(1, 0.1 * inch)) + + # Summary Table instead of charts + table_data = [["Metric / Category", "Value"]] + + # Tasks Status + status_counts = {row["label"]: row["value"] for row in charts.get("status", [])} + for label, val in status_counts.items(): + table_data.append([f"Tasks: {label}", str(val)]) + + # Categories + for cat in charts.get("categories", []): + table_data.append([f"Type: {cat['label']}", str(cat["value"])]) + + # Priorities + for priority in charts.get("priorities", []): + table_data.append([priority["label"], str(priority["value"])]) + + # Scores + for score in charts.get("scores", []): + table_data.append([f"Score: {score['label']}", str(score["value"])]) + + table = Table(table_data, colWidths=[3.5*inch, 1.5*inch]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0,0), (-1,0), colors.HexColor("#6e59ff")), + ('TEXTCOLOR', (0,0), (-1,0), colors.whitesmoke), + ('ALIGN', (0,0), (-1,-1), 'LEFT'), + ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), + ('BOTTOMPADDING', (0,0), (-1,0), 10), + ('BACKGROUND', (0,1), (-1,-1), colors.HexColor("#f8fafc")), + ('GRID', (0,0), (-1,-1), 0.5, colors.grey), + ('FONTSIZE', (0,0), (-1,-1), 9), + ])) + story.append(table) + story.append(Spacer(1, 0.3 * inch)) + + for raw_line in _report_body_without_execution_summary(content): + line = raw_line.strip() + if not line: + story.append(Spacer(1, 0.1 * inch)) + continue + if line.startswith("# "): + story.append(Paragraph(_pdf_text(line[2:]), styles["Title"])) + elif line.startswith("## "): + story.append(Paragraph(_pdf_text(line[3:]), styles["Heading2"])) + elif line.startswith("### "): + story.append(Paragraph(_pdf_text(line[4:]), styles["Heading3"])) + elif line.startswith("- "): + story.append(Paragraph(f"• {_pdf_text(line[2:])}", styles["BodyText"])) + else: + story.append(Paragraph(_pdf_text(line), styles["BodyText"])) + + doc.build(story) + return buffer.getvalue() + +class DebateRequest(BaseModel): + + task_id: str + agent_a_id: str + agent_b_id: str + + +class ProjectBudgetRequest(BaseModel): + enabled: bool = True + token_budget: int | None = None + cost_budget: float | None = None + currency: str = "USD" + +@router.post("/debate") +async def start_debate(request: DebateRequest, background_tasks: BackgroundTasks): + """ + Starts a debate between two agents for a specific task. + """ + background_tasks.add_task( + orchestrator_service.run_debate, + request.task_id, + request.agent_a_id, + request.agent_b_id + ) + return {"message": "Debate started in background"} + + +@router.post("/projects/{project_id}/run") +async def run_project_orchestrator(project_id: str, background_tasks: BackgroundTasks, use_queue: bool | None = None): + """ + Runs all queued tasks for a project in priority order. + """ + project_service.ensure_project_is_mutable(project_id) + should_queue = use_queue if use_queue is not None else False + if should_queue: + try: + result = await orchestrator_service.queue_project(project_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"message": "Project tasks queued for worker execution", **result} + + task = asyncio.create_task(orchestrator_service.run_project(project_id)) + task.add_done_callback(lambda current: log_async_task_result(current, f"run_project({project_id})")) + return {"message": "Project orchestrator started", "project_id": project_id, "mode": "direct"} + +@router.get("/projects/{project_id}/final-report") +async def get_project_final_report(project_id: str, variant: str = "full"): + """ + Builds a consolidated report from all approved task outputs. + """ + try: + return await orchestrator_service.build_final_report(project_id, variant) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/projects/{project_id}/evidence") +async def get_project_evidence(project_id: str, merge: bool = False): + """ + Returns normalized claims extracted from structured task outputs. + Can optionally merge semantically similar claims. + """ + project = supabase.table("projects").select("id").eq("id", project_id).single().execute().data + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if merge: + claims = await evidence_service.merge_project_claims(project_id) + else: + claims = evidence_service.load_project_claims(project_id) + + return { + "project_id": project_id, + "merged": merge, + "summary": evidence_service.summarize_claims(claims), + "claims": claims, + } + + +@router.get("/projects/{project_id}/budget") +async def get_project_budget(project_id: str): + project_service.get_project_or_404(project_id) + return budget_service.project_budget_status(project_id) + + +@router.put("/projects/{project_id}/budget") +async def update_project_budget(project_id: str, request: ProjectBudgetRequest): + project = supabase.table("projects").select("id").eq("id", project_id).single().execute().data + if not project: + raise HTTPException(status_code=404, detail="Project not found") + budget_service.upsert_project_budget( + project_id=project_id, + enabled=request.enabled, + token_budget=request.token_budget, + cost_budget=request.cost_budget, + currency=request.currency, + ) + return budget_service.project_budget_status(project_id) + + +@router.get("/projects/{project_id}/final-report.pdf") +async def download_project_final_report_pdf(project_id: str, variant: str = "full"): + """ + Downloads the selected report variant as a PDF. + """ + try: + result = await orchestrator_service.build_final_report(project_id, variant) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + title = f"{result['project_name']} - {result['variant']} report" + pdf = _report_pdf_bytes(title, result["report"], result.get("charts")) + filename = f"{_safe_filename(result['project_name'])}_{_safe_filename(result['variant'])}.pdf" + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) diff --git a/backend/scratch/check_db.py b/backend/scratch/check_db.py new file mode 100644 index 0000000000000000000000000000000000000000..463b7256718e13456d1084907a501c47be2277e9 --- /dev/null +++ b/backend/scratch/check_db.py @@ -0,0 +1,22 @@ +import os +from supabase import create_client +from dotenv import load_dotenv + +load_dotenv() + +supabase_url = os.getenv("SUPABASE_URL") +supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") +supabase = create_client(supabase_url, supabase_key) + +def check_logs(): + try: + res = supabase.table("agent_logs").select("*").order("created_at", desc=True).limit(20).execute() + print(f"Total logs retrieved: {len(res.data)}") + for log in res.data: + print(f"[{log['created_at']}] {log['action']}: {log['content'][:50]}...") + + except Exception as e: + print(f"Error accessing agent_logs: {e}") + +if __name__ == "__main__": + check_logs() diff --git a/backend/scratch/create_comparison_project.py b/backend/scratch/create_comparison_project.py new file mode 100644 index 0000000000000000000000000000000000000000..0b277511e5b48ca1db46ca7f13611eafe31b7abb --- /dev/null +++ b/backend/scratch/create_comparison_project.py @@ -0,0 +1,168 @@ +import os +from supabase import create_client +from dotenv import load_dotenv + +load_dotenv() + +supabase_url = os.getenv("SUPABASE_URL") +supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") +supabase = create_client(supabase_url, supabase_key) + +EXAMPLE_PROJECTS = [ + { + "project": { + "name": "Aubm Competitor Analysis", + "description": "Deep dive into the multi-agent orchestration market to identify Aubm's unique value proposition and feature gaps.", + "status": "active", + "context": "Focus on developer experience, visual observability, and the 'Agent Debate' mechanism as key differentiators." + }, + "tasks": [ + {"title": "Identify Top 5 Competitors", "description": "Research and list 5 similar multi-agent orchestration platforms (e.g., CrewAI, AutoGen, LangGraph, PydanticAI).", "status": "todo"}, + {"title": "Feature Comparison Matrix", "description": "Create a detailed matrix comparing Aubm's core features (Project Decomposition, Agent Debate, Real-time Console) against identified competitors.", "status": "todo"}, + {"title": "Pricing Model Analysis", "description": "Analyze how competitors charge (SaaS, Open Source, API usage) and recommend a competitive strategy for Aubm.", "status": "todo"}, + {"title": "UI/UX Aesthetic Audit", "description": "Evaluate the visual aesthetics and ease of use of competitors compared to Aubm's premium dashboard. Look for glassmorphism and animations.", "status": "todo"}, + {"title": "Technical Architecture Deep-Dive", "description": "Investigate the underlying tech stacks (Python vs TS, Vector DBs used, Orchestration logic) of top competitors.", "status": "todo"}, + {"title": "SWOT Analysis & Strategy Report", "description": "Compile all findings into a comprehensive report with a SWOT analysis and strategic recommendations for the next 6 months.", "status": "todo"} + ] + }, + { + "project": { + "name": "AI Support Automation Pilot", + "description": "Design a pilot that routes inbound support tickets through specialized AI agents while keeping human approval for risky replies.", + "status": "active", + "context": "Use this as a customer operations example. Emphasize ticket triage, escalation policies, response quality, and measurable SLA impact." + }, + "tasks": [ + {"title": "Map Support Ticket Categories", "description": "Identify the main ticket categories, escalation triggers, and data needed by each support agent role.", "status": "todo", "priority": 5}, + {"title": "Define Human Approval Rules", "description": "Specify which replies can be automated and which require human review based on customer risk and account tier.", "status": "todo", "priority": 4}, + {"title": "Design Agent Workflow", "description": "Create a multi-agent workflow for triage, answer drafting, policy checking, and final approval.", "status": "todo", "priority": 4}, + {"title": "Create Pilot Success Metrics", "description": "Define SLA, CSAT, deflection, review time, and error-rate metrics for a 30-day pilot.", "status": "todo", "priority": 3}, + {"title": "Draft Rollout Plan", "description": "Prepare a phased rollout plan with risks, staffing requirements, and customer communication steps.", "status": "todo", "priority": 3} + ] + }, + { + "project": { + "name": "FinOps Cloud Cost Review", + "description": "Analyze cloud infrastructure spend and propose agent-assisted monitoring workflows to reduce waste without hurting reliability.", + "status": "active", + "context": "Use this as an operations and finance example. Focus on anomaly detection, rightsizing, reserved capacity, and stakeholder reporting." + }, + "tasks": [ + {"title": "Inventory Cost Drivers", "description": "Break down the main cloud cost drivers across compute, storage, networking, databases, and third-party services.", "status": "todo", "priority": 5}, + {"title": "Identify Waste Patterns", "description": "Find common waste patterns such as idle resources, oversized instances, orphaned volumes, and expensive data transfer paths.", "status": "todo", "priority": 5}, + {"title": "Design Alerting Workflow", "description": "Create an agent workflow that detects spend anomalies, explains likely causes, and proposes owner-specific actions.", "status": "todo", "priority": 4}, + {"title": "Build Savings Roadmap", "description": "Prioritize savings opportunities by expected impact, risk, engineering effort, and time to value.", "status": "todo", "priority": 4}, + {"title": "Prepare Executive Summary", "description": "Summarize recommended actions, estimated savings ranges, risks, and governance changes for leadership.", "status": "todo", "priority": 3} + ] + }, + { + "project": { + "name": "Healthcare Intake Risk Triage", + "description": "Prototype an AI-assisted intake workflow that summarizes patient requests, flags urgency, and routes cases to the correct care team.", + "status": "active", + "context": "Use this as a regulated-industry example. Emphasize auditability, privacy, safety checks, and clear human-in-the-loop boundaries." + }, + "tasks": [ + {"title": "Define Intake Data Requirements", "description": "List required patient request fields, optional context, privacy constraints, and data that must never be generated by the system.", "status": "todo", "priority": 5}, + {"title": "Specify Risk Triage Rules", "description": "Define urgency categories, red-flag symptoms, routing criteria, and cases that must bypass automation.", "status": "todo", "priority": 5}, + {"title": "Design Audit Trail", "description": "Create an auditable record structure for summaries, agent reasoning, routing decisions, reviewer overrides, and timestamps.", "status": "todo", "priority": 4}, + {"title": "Review Compliance Risks", "description": "Identify privacy, consent, medical safety, bias, and operational risks with mitigation recommendations.", "status": "todo", "priority": 4}, + {"title": "Create Pilot Validation Plan", "description": "Define how clinicians will evaluate accuracy, escalation safety, workload impact, and patient experience before rollout.", "status": "todo", "priority": 3} + ] + }, + { + "project": { + "name": "Legal Contract Review Automation", + "description": "Create an agent-assisted workflow that reviews vendor contracts, flags risky clauses, and prepares negotiation notes for legal approval.", + "status": "active", + "context": "Use this as a legal operations example. Focus on contract risk, clause extraction, redlines, escalation thresholds, and attorney review." + }, + "tasks": [ + {"title": "Define Contract Review Scope", "description": "Identify contract types, clause families, review boundaries, and documents that must always be escalated to counsel.", "status": "todo", "priority": 5}, + {"title": "Build Clause Risk Taxonomy", "description": "Classify indemnity, limitation of liability, termination, data protection, payment, jurisdiction, and renewal risks.", "status": "todo", "priority": 5}, + {"title": "Design Legal Review Workflow", "description": "Create a multi-agent workflow for clause extraction, risk scoring, fallback research, negotiation notes, and final attorney approval.", "status": "todo", "priority": 4}, + {"title": "Draft Approval Checklist", "description": "Prepare a checklist for legal reviewers covering unacceptable terms, missing clauses, confidence levels, and required evidence.", "status": "todo", "priority": 4}, + {"title": "Prepare Pilot Metrics", "description": "Define cycle time, review accuracy, escalation rate, reviewer override rate, and business stakeholder satisfaction metrics.", "status": "todo", "priority": 3} + ] + }, + { + "project": { + "name": "Regulatory Compliance Monitoring", + "description": "Design a legal monitoring workflow that tracks regulatory changes, summarizes business impact, and routes obligations to owners.", + "status": "active", + "context": "Use this as a compliance example. Emphasize source traceability, jurisdiction filters, obligation mapping, audit logs, and risk-based prioritization." + }, + "tasks": [ + {"title": "Map Regulatory Sources", "description": "List official regulators, legal update feeds, jurisdictions, business units, and source reliability rules.", "status": "todo", "priority": 5}, + {"title": "Define Obligation Categories", "description": "Create categories for reporting, privacy, security, employment, financial controls, retention, and customer disclosure obligations.", "status": "todo", "priority": 5}, + {"title": "Design Change Detection Workflow", "description": "Create an agent workflow that detects changes, summarizes impact, links evidence, and assigns obligations to owners.", "status": "todo", "priority": 4}, + {"title": "Create Audit Evidence Model", "description": "Specify how the system stores source URLs, timestamps, summaries, reviewer decisions, owner acknowledgements, and completion proof.", "status": "todo", "priority": 4}, + {"title": "Prioritize Compliance Rollout", "description": "Rank jurisdictions and obligation types by legal exposure, operational complexity, and implementation effort.", "status": "todo", "priority": 3} + ] + }, + { + "project": { + "name": "Litigation Discovery Triage", + "description": "Prototype an AI-assisted discovery workflow that groups documents, identifies privilege risks, and prepares review batches for legal teams.", + "status": "active", + "context": "Use this as a litigation support example. Focus on defensibility, privilege review, chain of custody, reviewer queues, and evidence traceability." + }, + "tasks": [ + {"title": "Define Discovery Data Inputs", "description": "Identify document sources, metadata fields, custodians, date ranges, file types, and chain-of-custody requirements.", "status": "todo", "priority": 5}, + {"title": "Specify Privilege Screening Rules", "description": "Define attorney-client, work product, confidentiality, and sensitive data indicators that require legal review.", "status": "todo", "priority": 5}, + {"title": "Design Review Batch Workflow", "description": "Create an agent workflow for deduplication, clustering, privilege flagging, relevance summaries, and reviewer queue assignment.", "status": "todo", "priority": 4}, + {"title": "Create Defensibility Controls", "description": "Specify audit logs, reviewer overrides, confidence thresholds, sampled quality checks, and exportable decision records.", "status": "todo", "priority": 4}, + {"title": "Prepare Discovery Summary Report", "description": "Draft the report structure for document volumes, risk categories, review progress, escalations, and unresolved issues.", "status": "todo", "priority": 3} + ] + } +] + +def resolve_owner_id(): + existing_projects = supabase.table("projects").select("owner_id").limit(1).execute() + if existing_projects.data and existing_projects.data[0].get("owner_id"): + return existing_projects.data[0]["owner_id"] + + users = supabase.table("profiles").select("id").limit(1).execute() + if users.data: + return users.data[0]["id"] + + return None + +def create_project(project_data, tasks, owner_id): + existing = ( + supabase.table("projects") + .select("id") + .eq("name", project_data["name"]) + .limit(1) + .execute() + ) + if existing.data: + print(f"Skipping existing project: {project_data['name']}") + return + + payload = project_data.copy() + if owner_id: + payload["owner_id"] = owner_id + + project_res = supabase.table("projects").insert(payload).execute() + project_id = project_res.data[0]["id"] + task_rows = [{**task, "project_id": project_id} for task in tasks] + supabase.table("tasks").insert(task_rows).execute() + print(f"Created project: {project_data['name']} ({len(task_rows)} tasks)") + +def create_projects(): + try: + owner_id = resolve_owner_id() + if not owner_id: + print("No valid owner_id found in projects or profiles. The project will be created without owner and might not be visible.") + else: + print(f"Using owner_id: {owner_id}") + + for example in EXAMPLE_PROJECTS: + create_project(example["project"], example["tasks"], owner_id) + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + create_projects() diff --git a/backend/scratch/find_user.py b/backend/scratch/find_user.py new file mode 100644 index 0000000000000000000000000000000000000000..82765bfc3806a401ae9e457093c89103bc55a5a9 --- /dev/null +++ b/backend/scratch/find_user.py @@ -0,0 +1,24 @@ +import os +from supabase import create_client +from dotenv import load_dotenv + +load_dotenv() + +supabase_url = os.getenv("SUPABASE_URL") +supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") +supabase = create_client(supabase_url, supabase_key) + +def check_users(): + # Try different tables where users might be + tables = ["profiles", "users", "team_members"] + for table in tables: + try: + res = supabase.table(table).select("id").limit(1).execute() + print(f"Table {table} count: {len(res.data)}") + if res.data: + print(f"Sample ID: {res.data[0]['id']}") + except Exception as e: + print(f"Error checking {table}: {e}") + +if __name__ == "__main__": + check_users() diff --git a/backend/scratch/fix_logs_rls.py b/backend/scratch/fix_logs_rls.py new file mode 100644 index 0000000000000000000000000000000000000000..b6866a5455cba2f28634beed2a7f92527264bf0f --- /dev/null +++ b/backend/scratch/fix_logs_rls.py @@ -0,0 +1,33 @@ +import os +from supabase import create_client +from dotenv import load_dotenv + +load_dotenv() + +supabase_url = os.getenv("SUPABASE_URL") +supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") +supabase = create_client(supabase_url, supabase_key) + +sql = """ +ALTER TABLE agent_logs ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Enable read access for all users" ON agent_logs; +CREATE POLICY "Enable read access for all users" ON agent_logs FOR SELECT USING (true); +""" + +# Note: This assumes an 'exec_sql' RPC exists, which is common in many setups. +# If not, I'll have to find another way. +try: + # Actually, let's try a different approach if RPC fails. + # We can try to use the REST API to check if it works. + print("Attempting to set RLS policy...") + # Since I don't have direct SQL access via the client without RPC, + # I'll assume the user might need to do this in the dashboard or I'll try to find an RPC. + + # Let's check if the client can read with anon key. + anon_key = os.getenv("SUPABASE_ANON_KEY") + anon_s = create_client(supabase_url, anon_key) + res = anon_s.table("agent_logs").select("*").limit(1).execute() + print(f"Anon read test: {'Success' if not res.data else 'Empty/Restricted'}") + +except Exception as e: + print(f"Error: {e}") diff --git a/backend/services/agent_runner_service.py b/backend/services/agent_runner_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c2393a64bdc989eeff5000df80ed66da2279c703 --- /dev/null +++ b/backend/services/agent_runner_service.py @@ -0,0 +1,399 @@ +import logging +from datetime import datetime, timezone +from services.supabase_service import supabase +from services.audit_service import audit_service +from services.budget_service import BudgetExceededError, budget_service +from services.evidence_service import evidence_service +from agents.agent_factory import AgentFactory +from services.semantic_backprop import semantic_backprop +from services.output_quality import build_quality_instructions, validate_output +from services.memory_service import memory_service + +logger = logging.getLogger("agent_runner_service") + +def _update_task_run(run_id: str, payload: dict): + try: + return supabase.table("task_runs").update(payload).eq("id", run_id).execute() + except Exception as exc: + if "duration_seconds" in payload and "duration_seconds" in str(exc) and "schema cache" in str(exc): + fallback_payload = {key: value for key, value in payload.items() if key != "duration_seconds"} + logger.warning("task_runs.duration_seconds is missing in Supabase schema; retrying run update without duration.") + return supabase.table("task_runs").update(fallback_payload).eq("id", run_id).execute() + raise + +class AgentRunnerService: + @staticmethod + async def run_agent_task( + task: dict, + agent_data: dict, + *, + include_semantic_context: bool = False, + start_action: str = "execution_start", + start_content: str | None = None, + complete_action: str = "execution_complete", + complete_content: str = "Agent successfully completed the task and produced output.", + update_task: bool = True + ) -> tuple[dict, str]: + task_id = task["id"] + project_id = task["project_id"] + run_id = None + + if update_task: + supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_status_changed", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={"project_id": project_id, "status": "in_progress"}, + ) + + try: + run_res = supabase.table("task_runs").insert({ + "task_id": task_id, + "agent_id": agent_data["id"], + "status": "running" + }).execute() + run_id = run_res.data[0]["id"] + await audit_service.log_action( + user_id=None, + action="task_run_created", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={"project_id": project_id, "run_id": run_id, "status": "running"}, + ) + + # Emergency Model Override for decommissioned Groq models + model_to_use = agent_data["model"] + if "llama3-70b-8192" in model_to_use: + model_to_use = "llama-3.3-70b-versatile" + logger.warning(f"Overriding decommissioned model {agent_data['model']} with {model_to_use}") + + agent = AgentFactory.get_agent( + provider=agent_data["api_provider"], + name=agent_data["name"], + role=agent_data["role"], + model=model_to_use, + system_prompt=agent_data.get("system_prompt") + ) + + context_res = supabase.table("tasks").select("title, output_data") \ + .eq("project_id", project_id) \ + .eq("status", "done") \ + .execute() + context = context_res.data if context_res.data else [] + + project_data = task.get("project") + if not isinstance(project_data, dict): + project_res = ( + supabase.table("projects") + .select("name,description,context") + .eq("id", project_id) + .single() + .execute() + ) + project_data = project_res.data if project_res and project_res.data else {} + quality_task = {**task, "project": project_data} + + extra_context = "" + if include_semantic_context: + extra_context = await semantic_backprop.get_project_context(project_id, task_id) + # Fetch Long-Term Memory (Cross-project) + memories = await memory_service.search_memory( + query=task.get("description") or task["title"], + limit=3, + threshold=0.72 + ) + if memories: + memory_header = "\n\n### RELEVANT HISTORICAL CONTEXT (CROSS-PROJECT)\n" + memory_blocks = [] + for m in memories: + memory_blocks.append(f"- Memory: {m['content']}") + extra_context += memory_header + "\n".join(memory_blocks) + + # Fetch Self-Optimization Lessons for this specific task + lessons_res = supabase.table("project_memory") \ + .select("content") \ + .eq("task_id", task_id) \ + .eq("memory_type", "self_optimization_lesson") \ + .order("created_at", desc=True) \ + .limit(1) \ + .execute() + + if lessons_res.data: + lesson = lessons_res.data[0]["content"] + extra_context += f"\n\n### CRITICAL LESSON FROM PREVIOUS ATTEMPT\n{lesson}\n" + + import time + import hashlib + + # Simple in-memory cache for the session (could be persistent later) + if not hasattr(AgentRunnerService, "_task_cache"): + AgentRunnerService._task_cache = {} + + # 1. Create a cache key based on task, agent (model + system prompt), and context + cache_input = f"{task['id']}-{agent_data['model']}-{agent_data.get('system_prompt', '')}-{task.get('description')}-{str(context)}-{extra_context}" + cache_key = hashlib.md5(cache_input.encode()).hexdigest() + + # 2. Check Cache + if cache_key in AgentRunnerService._task_cache: + logger.info(f"Cache hit for task {task_id}. Skipping LLM call.") + cached_result = AgentRunnerService._task_cache[cache_key] + claims_count = await evidence_service.replace_task_claims(task, cached_result) + + # Still log the "start" for UI consistency + agent_name = agent_data.get('name', 'Agent') + log_msg = start_content or f"Agent {agent_name} resuming task" + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": start_action, + "content": f"[CACHE HIT] {log_msg}" + }).execute() + + if update_task: + supabase.table("tasks").update({ + "status": "awaiting_approval", + "output_data": cached_result + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_status_changed", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={ + "project_id": project_id, + "run_id": run_id, + "status": "awaiting_approval", + "cache_hit": True, + "claims_count": claims_count, + }, + ) + + _update_task_run(run_id, { + "status": "completed", + "finished_at": datetime.now(timezone.utc).isoformat() + }) + + return cached_result, run_id + + # 3. Log Start + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": start_action, + "content": start_content or f"Agent {agent_data['name']} starting task: {task['title']}" + }).execute() + + # 4. Execute Run with timing + start_time = time.time() + task_instructions = task.get("description") or task["title"] + task_instructions = f"{task_instructions}\n\n{build_quality_instructions(quality_task)}" + prompt_tokens = budget_service.estimate_prompt_tokens( + task_instructions=task_instructions, + context=context, + extra_context=extra_context, + system_prompt=agent_data.get("system_prompt"), + ) + max_completion_tokens = int(getattr(agent, "max_tokens", 0) or 0) + estimated_preflight_cost = budget_service.estimate_cost( + agent_data.get("api_provider"), + agent_data.get("model"), + prompt_tokens, + max_completion_tokens, + ) + budget_service.check_before_run( + project_id=project_id, + estimated_tokens=prompt_tokens + max_completion_tokens, + estimated_cost=estimated_preflight_cost, + ) + result = await agent.run(task_instructions, context, extra_context=extra_context) + duration = time.time() - start_time + + if result.get("status") == "error": + raise RuntimeError(result.get("error") or "Agent returned an error result.") + + # 5. Security Sanitization (Defense in Depth) + raw_out = str(result.get("raw_output", "")) + suspicious_patterns = ["rm -rf", "mkfs", "dd if=", "curl", "wget", "chmod 777", "> /dev/sda"] + for pattern in suspicious_patterns: + if pattern in raw_out: + logger.warning(f"SECURITY: Suspicious pattern '{pattern}' detected in agent output for task {task_id}.") + result["security_warning"] = f"Output sanitized: suspicious pattern '{pattern}' detected." + + quality_review = validate_output(quality_task, result) + result["quality_review"] = quality_review + claims_count = await evidence_service.replace_task_claims(task, result) + + # Use actual usage if provided by agent, otherwise fallback to estimation + usage = result.get("usage") or {} + actual_prompt_tokens = usage.get("prompt_tokens") or prompt_tokens + actual_completion_tokens = usage.get("completion_tokens") or budget_service.estimate_completion_tokens(result) + + actual_cost = budget_service.estimate_cost( + agent_data.get("api_provider"), + agent_data.get("model"), + actual_prompt_tokens, + actual_completion_tokens, + ) + + budget_service.record_usage( + project_id=project_id, + task_id=task_id, + run_id=run_id, + agent_id=agent_data.get("id"), + provider=agent_data.get("api_provider"), + model=agent_data.get("model"), + prompt_tokens=actual_prompt_tokens, + completion_tokens=actual_completion_tokens, + estimated_cost=actual_cost, + metadata={"duration_seconds": round(duration, 2), "claims_count": claims_count, "usage_source": "api" if result.get("usage") else "estimation"}, + ) + + # 6. Save to Cache + AgentRunnerService._task_cache[cache_key] = result + + if update_task: + supabase.table("tasks").update({ + "status": "awaiting_approval", + "output_data": result + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_status_changed", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={ + "project_id": project_id, + "run_id": run_id, + "status": "awaiting_approval", + "quality_approved": quality_review["approved"], + "claims_count": claims_count, + "estimated_tokens": actual_prompt_tokens + actual_completion_tokens, + "estimated_cost": float(actual_cost), + }, + ) + + # 7. Update Run Status + _update_task_run(run_id, { + "status": "completed", + "finished_at": datetime.now(timezone.utc).isoformat(), + "duration_seconds": round(duration, 2) + }) + + # 8. Log Completion with Metrics + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": complete_action, + "content": f"{complete_content} (Execution time: {duration:.2f}s)" + }).execute() + + if not quality_review["approved"]: + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": "quality_review_failed", + "content": f"Quality review failed: {', '.join(quality_review['fail_reasons'])}" + }).execute() + await audit_service.log_action( + user_id=None, + action="task_quality_review_failed", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={ + "project_id": project_id, + "run_id": run_id, + "fail_reasons": quality_review.get("fail_reasons", []), + }, + ) + + return result, run_id + + except BudgetExceededError as e: + logger.warning(f"Budget blocked task {task_id}: {str(e)}") + if run_id: + _update_task_run(run_id, { + "status": "cancelled", + "error_message": str(e), + "finished_at": datetime.now(timezone.utc).isoformat() + }) + + if update_task: + supabase.table("tasks").update({ + "status": "failed", + "output_data": {"error": str(e), "budget_blocked": True} + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_budget_blocked", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={"project_id": project_id, "run_id": run_id, "error": str(e)}, + ) + + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": "budget_blocked", + "content": f"Budget blocked execution: {str(e)}" + }).execute() + + raise e + + except Exception as e: + logger.error(f"Error executing task {task_id}: {str(e)}") + if run_id: + _update_task_run(run_id, { + "status": "failed", + "finished_at": datetime.now(timezone.utc).isoformat() + }) + + if update_task: + supabase.table("tasks").update({ + "status": "failed", + "output_data": {"error": str(e)} + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_status_changed", + agent_id=agent_data.get("id"), + task_id=task_id, + metadata={ + "project_id": project_id, + "run_id": run_id, + "status": "failed", + "error": str(e), + }, + ) + + # LOG ERROR TO AGENT CONSOLE + supabase.table("agent_logs").insert({ + "task_id": task_id, + "run_id": run_id, + "action": "execution_failed", + "content": f"ERROR: {str(e)}" + }).execute() + + raise e + + @staticmethod + async def execute_agent_logic(task: dict, agent_data: dict): + task_id = task["id"] + try: + await AgentRunnerService.run_agent_task( + task, + agent_data, + include_semantic_context=True + ) + + await audit_service.log_action( + user_id=None, + action="agent_task_completed", + agent_id=agent_data["id"], + task_id=task_id, + metadata={"model": agent_data["model"]} + ) + + except Exception: + raise diff --git a/backend/services/audit_service.py b/backend/services/audit_service.py new file mode 100644 index 0000000000000000000000000000000000000000..df517c90c55e37906401b185c40310d6ba72fdb9 --- /dev/null +++ b/backend/services/audit_service.py @@ -0,0 +1,31 @@ +from services.supabase_service import supabase +from typing import Dict, Any, Optional +import logging + +logger = logging.getLogger("uvicorn") + +class AuditService: + @staticmethod + async def log_action( + user_id: Optional[str], + action: str, + agent_id: Optional[str] = None, + task_id: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ): + """ + Records an action in the audit_logs table. + """ + try: + data = { + "user_id": user_id, + "action": action, + "agent_id": agent_id, + "task_id": task_id, + "metadata": metadata or {} + } + supabase.table("audit_logs").insert(data).execute() + except Exception as e: + logger.error(f"AuditService error: {str(e)}") + +audit_service = AuditService() diff --git a/backend/services/budget_service.py b/backend/services/budget_service.py new file mode 100644 index 0000000000000000000000000000000000000000..7ba54c3fb804f0835ed5884f79552d536467dd7e --- /dev/null +++ b/backend/services/budget_service.py @@ -0,0 +1,208 @@ +import logging +from decimal import Decimal +from typing import Any + +from services.config import config_service + +logger = logging.getLogger("budget_service") + + +def _estimate_tokens(value: Any) -> int: + text = str(value or "") + if not text.strip(): + return 0 + return max(1, len(text) // 4) + + +def _safe_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")) + except Exception: + return Decimal("0") + + +class BudgetExceededError(RuntimeError): + pass + + +class BudgetService: + @staticmethod + def estimate_prompt_tokens( + *, + task_instructions: str, + context: list[dict], + extra_context: str, + system_prompt: str | None, + ) -> int: + return ( + _estimate_tokens(task_instructions) + + _estimate_tokens(context) + + _estimate_tokens(extra_context) + + _estimate_tokens(system_prompt) + ) + + @staticmethod + def estimate_completion_tokens(result: dict) -> int: + if not isinstance(result, dict): + return _estimate_tokens(result) + return _estimate_tokens(result.get("raw_output") or result.get("data") or result) + + @staticmethod + def estimate_cost(provider: str | None, model: str | None, prompt_tokens: int, completion_tokens: int) -> Decimal: + pricing = config_service.get_global_setting("model_pricing", {}) or {} + keys = [ + f"{provider}:{model}" if provider and model else None, + str(model) if model else None, + str(provider) if provider else None, + ] + price = next((pricing.get(key) for key in keys if key and key in pricing), None) + if not isinstance(price, dict): + return Decimal("0") + + input_per_1k = _safe_decimal(price.get("input_per_1k")) + output_per_1k = _safe_decimal(price.get("output_per_1k")) + return ( + (Decimal(prompt_tokens) / Decimal(1000)) * input_per_1k + + (Decimal(completion_tokens) / Decimal(1000)) * output_per_1k + ).quantize(Decimal("0.000001")) + + @staticmethod + def _load_budget(project_id: str) -> dict | None: + try: + from services.supabase_service import supabase + + response = supabase.table("project_budgets").select("*").eq("project_id", project_id).execute() + return response.data[0] if response.data else None + except Exception as exc: + logger.warning("Could not load project budget for %s: %s", project_id, exc) + return None + + @staticmethod + def _usage_totals(project_id: str) -> dict: + try: + from services.supabase_service import supabase + + rows = ( + supabase.table("project_usage_events") + .select("total_tokens,estimated_cost") + .eq("project_id", project_id) + .execute() + .data + or [] + ) + except Exception as exc: + logger.warning("Could not load project usage for %s: %s", project_id, exc) + return {"total_tokens": 0, "estimated_cost": Decimal("0")} + + return { + "total_tokens": sum(int(row.get("total_tokens") or 0) for row in rows), + "estimated_cost": sum((_safe_decimal(row.get("estimated_cost")) for row in rows), Decimal("0")), + } + + @classmethod + def project_budget_status(cls, project_id: str) -> dict: + budget = cls._load_budget(project_id) + usage = cls._usage_totals(project_id) + token_budget = int(budget["token_budget"]) if budget and budget.get("token_budget") is not None else None + cost_budget = _safe_decimal(budget.get("cost_budget")) if budget and budget.get("cost_budget") is not None else None + + return { + "project_id": project_id, + "budget": budget, + "usage": { + "total_tokens": usage["total_tokens"], + "estimated_cost": float(usage["estimated_cost"]), + }, + "remaining": { + "tokens": max(token_budget - usage["total_tokens"], 0) if token_budget is not None else None, + "cost": float(max(cost_budget - usage["estimated_cost"], Decimal("0"))) if cost_budget is not None else None, + }, + } + + @staticmethod + def upsert_project_budget( + *, + project_id: str, + enabled: bool = True, + token_budget: int | None = None, + cost_budget: float | None = None, + currency: str = "USD", + ) -> dict: + try: + from services.supabase_service import supabase + + payload = { + "project_id": project_id, + "enabled": enabled, + "token_budget": token_budget, + "cost_budget": cost_budget, + "currency": currency or "USD", + } + response = supabase.table("project_budgets").upsert(payload, on_conflict="project_id").execute() + return response.data[0] if response.data else payload + except Exception as exc: + logger.warning("Could not upsert project budget for %s: %s", project_id, exc) + raise + + @classmethod + def check_before_run( + cls, + *, + project_id: str, + estimated_tokens: int, + estimated_cost: Decimal, + ) -> dict: + budget = cls._load_budget(project_id) + if not budget or not budget.get("enabled", True): + return {"allowed": True, "budget": budget, "usage": None} + + usage = cls._usage_totals(project_id) + token_budget = budget.get("token_budget") + if token_budget is not None and usage["total_tokens"] + estimated_tokens > int(token_budget): + raise BudgetExceededError( + f"Project token budget exceeded: {usage['total_tokens']} used + {estimated_tokens} estimated > {token_budget}." + ) + + cost_budget = budget.get("cost_budget") + if cost_budget is not None and usage["estimated_cost"] + estimated_cost > _safe_decimal(cost_budget): + raise BudgetExceededError( + f"Project cost budget exceeded: {usage['estimated_cost']} used + {estimated_cost} estimated > {cost_budget}." + ) + + return {"allowed": True, "budget": budget, "usage": usage} + + @staticmethod + def record_usage( + *, + project_id: str, + task_id: str, + run_id: str | None, + agent_id: str | None, + provider: str | None, + model: str | None, + prompt_tokens: int, + completion_tokens: int, + estimated_cost: Decimal, + metadata: dict | None = None, + ) -> None: + try: + from services.supabase_service import supabase + + supabase.table("project_usage_events").insert({ + "project_id": project_id, + "task_id": task_id, + "run_id": run_id, + "agent_id": agent_id, + "provider": provider, + "model": model, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + "estimated_cost": float(estimated_cost), + "metadata": metadata or {}, + }).execute() + except Exception as exc: + logger.warning("Could not record project usage for task %s: %s", task_id, exc) + + +budget_service = BudgetService() diff --git a/backend/services/config.py b/backend/services/config.py new file mode 100644 index 0000000000000000000000000000000000000000..a333f4961c109f73cfe6c01fdf092b0d7b7eff98 --- /dev/null +++ b/backend/services/config.py @@ -0,0 +1,107 @@ +import os +from pydantic_settings import BaseSettings +from typing import Optional, Dict, Any +from supabase import create_client, Client + +class Settings(BaseSettings): + # Supabase + SUPABASE_URL: str = "" + SUPABASE_SERVICE_ROLE_KEY: str = "" + + # AI Providers + OPENAI_API_KEY: Optional[str] = None + GROQ_API_KEY: Optional[str] = None + GEMINI_API_KEY: Optional[str] = None + ANTHROPIC_API_KEY: Optional[str] = None + AMD_API_KEY: Optional[str] = None + TAVILY_API_KEY: Optional[str] = None + + # Infrastructure (DigitalOcean) + DO_API_TOKEN: Optional[str] = None + DO_INFERENCE_KEY: Optional[str] = None + DO_AGENT_ACCESS_KEY: Optional[str] = None + DO_AGENT_ENDPOINT: Optional[str] = None + DO_REGION: str = "nyc3" + + # App Config + TASK_QUEUE_EMBEDDED_WORKER: bool = True + TASK_QUEUE_HEARTBEAT_ENABLED: bool = True + TASK_EXECUTION_MODE: str = "queue" # direct | queue + TASK_QUEUE_IDLE_POLL_SECONDS: int = 60 + OUTPUT_LANGUAGE: str = "en" + PORT: int = 8000 + SENTRY_DSN: Optional[str] = None + + model_config = { + "env_file": ".env", + "extra": "ignore" + } + +settings = Settings() + +class ConfigService: + """ + Manages application-wide settings stored in Supabase with local fallback defaults. + Borrowed from AgentCollab for enhanced flexibility. + """ + _cache: Dict[str, Any] = {} + _supabase: Client = None + + @classmethod + def _get_supabase(cls): + if not cls._supabase: + if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY: + return None + cls._supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY) + return cls._supabase + + # Defaults used when DB has no config entry for a provider + _DEFAULTS: Dict[str, Any] = { + "groq": {"enabled": True, "default_model": "llama-3.3-70b-versatile", "temperature": 0.7, "max_tokens": 4096}, + "openai": {"enabled": True, "default_model": "gpt-4o", "temperature": 0.7, "max_tokens": 4096}, + "openrouter": {"enabled": True, "default_model": "google/gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192}, + "gemini": {"enabled": True, "default_model": "gemini-2.0-flash", "temperature": 0.7, "max_tokens": 8192}, + "amd": {"enabled": True, "default_model": "llama-3.3-70b-instruct", "temperature": 0.7, "max_tokens": 4096, "base_url": "https://inference.do-ai.run/v1"}, + "ollama": {"enabled": True, "default_model": "llama3.1:8b", "temperature": 0.7, "base_url": "http://localhost:11434"}, + } + + @classmethod + def get_provider_config(cls, provider: str) -> Dict[str, Any]: + """Returns config for a provider from cache, DB, then defaults.""" + cache_key = f"provider:{provider}" + if cache_key in cls._cache: + return cls._cache[cache_key] + + db = cls._get_supabase() + if db: + try: + resp = db.table("app_config").select("*").eq("key", provider).execute() + if resp.data and len(resp.data) > 0: + cls._cache[cache_key] = resp.data[0]["value"] + return cls._cache[cache_key] + except Exception: + pass # Fall through to defaults + + result = cls._DEFAULTS.get(provider, {}) + cls._cache[cache_key] = result + return result + + @classmethod + def get_global_setting(cls, key: str, default: Any = None) -> Any: + cache_key = f"global:{key}" + if cache_key in cls._cache: + return cls._cache[cache_key] + + db = cls._get_supabase() + if db: + try: + resp = db.table("app_config").select("*").eq("key", key).execute() + if resp.data and len(resp.data) > 0: + cls._cache[cache_key] = resp.data[0]["value"] + return cls._cache[cache_key] + except Exception: + pass + + return default + +config_service = ConfigService() diff --git a/backend/services/embedding_service.py b/backend/services/embedding_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9b1933c48fca100ac6d39d0c6149ac003cef9816 --- /dev/null +++ b/backend/services/embedding_service.py @@ -0,0 +1,87 @@ +import logging +import numpy as np +from typing import List, Optional +import openai +from services.config import settings + +logger = logging.getLogger("embedding_service") + +class EmbeddingService: + """ + Handles text vectorization for semantic deduplication and retrieval. + """ + def __init__(self): + self.client = None + self.model = "text-embedding-3-small" + if settings.OPENAI_API_KEY: + try: + self.client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + except Exception as e: + logger.error(f"Failed to initialize OpenAI client for embeddings: {e}") + + async def get_embeddings(self, texts: List[str]) -> List[List[float]]: + """ + Batch fetches embeddings for a list of strings. + """ + if not settings.OPENAI_API_KEY or not self.client: + logger.debug("OpenAI embeddings not available (missing key or initialization failed).") + return [] + + if not texts: + return [] + + try: + # Cleanup texts to avoid API errors on empty/null inputs + clean_texts = [str(t)[:8000] for t in texts if t] + if not clean_texts: + return [] + + response = await self.client.embeddings.create( + input=clean_texts, + model=self.model + ) + return [data.embedding for data in response.data] + except Exception as e: + logger.error(f"Failed to fetch embeddings: {e}") + return [] + + def cosine_similarity(self, vec_a: List[float], vec_b: List[float]) -> float: + """ + Calculates cosine similarity between two vectors. + """ + a = np.array(vec_a) + b = np.array(vec_b) + if not a.any() or not b.any(): + return 0.0 + return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) + + async def find_duplicates(self, new_claims: List[str], existing_claims: List[str], threshold: float = 0.85) -> List[Optional[int]]: + """ + For each new claim, finds the index of a semantically similar existing claim. + Returns a list of indices or None if no match found. + """ + if not new_claims or not existing_claims: + return [None] * len(new_claims) + + new_vecs = await self.get_embeddings(new_claims) + existing_vecs = await self.get_embeddings(existing_claims) + + if not new_vecs or not existing_vecs: + return [None] * len(new_claims) + + results = [] + for n_vec in new_vecs: + best_idx = None + best_score = -1.0 + + for idx, e_vec in enumerate(existing_vecs): + score = self.cosine_similarity(n_vec, e_vec) + if score > threshold and score > best_score: + best_score = score + best_idx = idx + + results.append(best_idx) + + return results + +embedding_service = EmbeddingService() diff --git a/backend/services/evidence_service.py b/backend/services/evidence_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e6e25b696314b514e84e21bbe5fb0dbe75ab7e13 --- /dev/null +++ b/backend/services/evidence_service.py @@ -0,0 +1,315 @@ +import logging +import hashlib +import re +import unicodedata +from typing import Any + +from services.task_schemas import parse_structured_payload + +logger = logging.getLogger("evidence_service") + + +def _primary_payload(output_data: dict) -> Any: + data = output_data.get("data") + if data not in (None, "", [], {}): + return parse_structured_payload(data) if isinstance(data, str) else data + return parse_structured_payload(output_data.get("raw_output")) + + +def _clean_text(value: Any) -> str: + return str(value or "").strip() + + +def normalize_entity_key(value: Any) -> str | None: + text = _clean_text(value) + if not text: + return None + normalized = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + normalized = normalized.lower() + normalized = re.sub(r"\b(inc|llc|ltd|corp|corporation|company|co|sa|s\.a\.)\b", "", normalized) + normalized = re.sub(r"[^a-z0-9]+", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized or None + + +def normalize_claim_text(value: Any) -> str: + text = unicodedata.normalize("NFKD", _clean_text(value)).encode("ascii", "ignore").decode("ascii") + text = text.lower() + text = re.sub(r"https?://\S+", "", text) + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def claim_hash( + project_id: str | None, + claim_text: str, + entity_name: str | None = None, + entity_key: str | None = None, +) -> str: + key = "|".join([ + project_id or "", + entity_key or normalize_entity_key(entity_name) or "", + normalize_claim_text(claim_text), + ]) + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + +def _claim_row( + *, + project_id: str | None, + task_id: str | None, + claim_text: str, + claim_type: str, + entity_name: str | None = None, + source_url: str | None = None, + confidence: str = "unknown", + metadata: dict | None = None, + alias_map: dict[str, str] | None = None, +) -> dict: + raw_entity_key = normalize_entity_key(entity_name) + entity_key = (alias_map or {}).get(raw_entity_key or "", raw_entity_key) + return { + "project_id": project_id, + "task_id": task_id, + "claim_text": claim_text, + "claim_type": claim_type, + "entity_name": entity_name, + "entity_key": entity_key, + "claim_hash": claim_hash(project_id, claim_text, entity_name, entity_key), + "source_url": source_url, + "confidence": confidence, + "metadata": metadata or {}, + } + + +class EvidenceService: + @staticmethod + def load_alias_map(project_id: str | None) -> dict[str, str]: + if not project_id: + return {} + try: + from services.supabase_service import supabase + + rows = ( + supabase.table("project_entity_aliases") + .select("alias_key,canonical_key") + .eq("project_id", project_id) + .execute() + .data + or [] + ) + except Exception as exc: + logger.warning("Could not load entity aliases for project %s: %s", project_id, exc) + return {} + + aliases: dict[str, str] = {} + for row in rows: + alias_key = row.get("alias_key") + canonical_key = row.get("canonical_key") + if alias_key and canonical_key: + aliases[alias_key] = canonical_key + return aliases + + @staticmethod + def load_project_claims(project_id: str) -> list[dict]: + try: + from services.supabase_service import supabase + + return ( + supabase.table("task_claims") + .select("claim_text,claim_type,entity_name,entity_key,source_url,confidence,task_id,created_at") + .eq("project_id", project_id) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + except Exception as exc: + logger.warning("Could not load task claims for project %s: %s", project_id, exc) + return [] + + @staticmethod + def summarize_claims(claims: list[dict]) -> dict: + by_type: dict[str, int] = {} + by_entity: dict[str, int] = {} + sourced_count = 0 + + for claim in claims: + claim_type = claim.get("claim_type") or "unknown" + by_type[claim_type] = by_type.get(claim_type, 0) + 1 + + entity = claim.get("entity_name") or claim.get("entity_key") or "Unassigned" + by_entity[entity] = by_entity.get(entity, 0) + 1 + + source_url = claim.get("source_url") + if isinstance(source_url, str) and source_url.startswith(("http://", "https://")): + sourced_count += 1 + + total_count = len(claims) + return { + "claim_count": total_count, + "sourced_claim_count": sourced_count, + "unsourced_claim_count": max(total_count - sourced_count, 0), + "source_coverage": round(sourced_count / total_count, 4) if total_count else 0, + "by_type": dict(sorted(by_type.items())), + "by_entity": dict(sorted(by_entity.items(), key=lambda item: item[1], reverse=True)), + } + + @staticmethod + def extract_claims(task: dict, output_data: dict) -> list[dict]: + payload = _primary_payload(output_data) + if not isinstance(payload, dict): + return [] + + project_id = task.get("project_id") + task_id = task.get("id") + alias_map = EvidenceService.load_alias_map(project_id) + claims: list[dict] = [] + + for finding in payload.get("findings") or []: + if not isinstance(finding, dict): + continue + claim_text = _clean_text(finding.get("claim")) + if not claim_text: + continue + claims.append(_claim_row( + project_id=project_id, + task_id=task_id, + claim_text=claim_text, + claim_type="finding", + entity_name=_clean_text(finding.get("entity")) or None, + source_url=_clean_text(finding.get("source_url")) or None, + confidence=finding.get("confidence") if finding.get("confidence") in ("low", "medium", "high") else "unknown", + metadata={"schema_source": "findings"}, + alias_map=alias_map, + )) + + for entity in payload.get("entities") or []: + if not isinstance(entity, dict): + continue + entity_name = _clean_text(entity.get("name")) + source_url = _clean_text(entity.get("source_url")) or None + for key, claim_type in (("strengths", "entity_strength"), ("weaknesses", "entity_weakness")): + for item in entity.get(key) or []: + claim_text = _clean_text(item) + if not claim_text: + continue + claims.append(_claim_row( + project_id=project_id, + task_id=task_id, + claim_text=claim_text, + claim_type=claim_type, + entity_name=entity_name or None, + source_url=source_url, + confidence="unknown", + metadata={"schema_source": f"entities.{key}", "category": entity.get("category")}, + alias_map=alias_map, + )) + + for recommendation in payload.get("recommendations") or []: + if not isinstance(recommendation, dict): + continue + claim_text = _clean_text(recommendation.get("title") or recommendation.get("rationale")) + if not claim_text: + continue + claims.append(_claim_row( + project_id=project_id, + task_id=task_id, + claim_text=claim_text, + claim_type="recommendation", + metadata=recommendation, + )) + + for risk in payload.get("risks") or []: + claim_text = _clean_text(risk) + if not claim_text: + continue + claims.append(_claim_row( + project_id=project_id, + task_id=task_id, + claim_text=claim_text, + claim_type="risk", + metadata={"schema_source": "risks"}, + )) + + deduped: dict[str, dict] = {} + for claim in claims: + deduped.setdefault(claim["claim_hash"], claim) + return list(deduped.values()) + + @staticmethod + async def replace_task_claims(task: dict, output_data: dict) -> int: + task_id = task.get("id") + if not task_id: + return 0 + + claims = EvidenceService.extract_claims(task, output_data) + try: + from services.supabase_service import supabase + + supabase.table("task_claims").delete().eq("task_id", task_id).execute() + if claims: + supabase.table("task_claims").upsert( + claims, + on_conflict="project_id,claim_hash", + ).execute() + return len(claims) + except Exception as exc: + logger.warning("Could not persist task claims for %s: %s", task_id, exc) + return 0 + + @staticmethod + async def merge_project_claims(project_id: str, threshold: float = 0.88) -> list[dict]: + """ + Groups similar claims within a project and returns a consolidated set. + """ + from services.embedding_service import embedding_service + + claims = EvidenceService.load_project_claims(project_id) + if len(claims) < 2: + return claims + + # Extract texts for embedding + texts = [c["claim_text"] for c in claims] + embeddings = await embedding_service.get_embeddings(texts) + if not embeddings: + return claims + + merged: list[dict] = [] + used_indices: set[int] = set() + + for i in range(len(claims)): + if i in used_indices: + continue + + base_claim = claims[i].copy() + used_indices.add(i) + + # Look for matches in the rest of the claims + for j in range(i + 1, len(claims)): + if j in used_indices: + continue + + similarity = embedding_service.cosine_similarity(embeddings[i], embeddings[j]) + if similarity >= threshold: + used_indices.add(j) + # Merge logic: Append sources, keep longest text, etc. + other_claim = claims[j] + if len(other_claim["claim_text"]) > len(base_claim["claim_text"]): + base_claim["claim_text"] = other_claim["claim_text"] + + # Consolidate sources (metadata) + if other_claim.get("source_url") and not base_claim.get("source_url"): + base_claim["source_url"] = other_claim["source_url"] + + # Track that this claim was merged + if "merged_count" not in base_claim: + base_claim["merged_count"] = 1 + base_claim["merged_count"] += 1 + + merged.append(base_claim) + + return merged + + +evidence_service = EvidenceService() diff --git a/backend/services/infrastructure_service.py b/backend/services/infrastructure_service.py new file mode 100644 index 0000000000000000000000000000000000000000..cc370f83d1397271ff012176ef5e963988c0a19d --- /dev/null +++ b/backend/services/infrastructure_service.py @@ -0,0 +1,97 @@ +import httpx +import logging +import asyncio +from typing import Optional, Dict, Any +from .config import settings + +logger = logging.getLogger("infrastructure") + +class InfrastructureService: + """ + Manages on-the-fly compute resources on DigitalOcean for AI inference. + """ + API_URL = "https://api.digitalocean.com/v2" + + def __init__(self): + self.headers = { + "Authorization": f"Bearer {settings.DO_API_TOKEN}", + "Content-Type": "application/json" + } + + async def create_inference_node(self, name: str, size: str = "s-4vcpu-8gb-amd") -> Optional[Dict[str, Any]]: + """ + Provision a new AMD-based droplet with Ollama pre-installed. + Default size is a capable AMD-based node. + """ + if not settings.DO_API_TOKEN: + logger.error("DO_API_TOKEN not configured.") + return None + + # Cloud-init script to setup the inference environment + user_data = """#cloud-config +runcmd: + - curl -fsSL https://get.docker.com | sh + - docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama -e OLLAMA_HOST=0.0.0.0 ollama/ollama + - sleep 10 + - docker exec ollama ollama pull llama3 +""" + + payload = { + "name": name, + "region": settings.DO_REGION, + "size": size, + "image": "ubuntu-22-04-x64", + "user_data": user_data, + "tags": ["aubm-worker", "inference-node"] + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post(f"{self.API_URL}/droplets", headers=self.headers, json=payload) + response.raise_for_status() + data = response.json() + droplet_id = data["droplet"]["id"] + logger.info(f"Inference node creation initiated: {name} (ID: {droplet_id})") + return data["droplet"] + except Exception as e: + logger.error(f"Failed to create droplet: {e}") + return None + + async def wait_for_ip(self, droplet_id: int, timeout: int = 300) -> Optional[str]: + """ + Polls the API until the droplet has a public IP assigned. + """ + start_time = asyncio.get_event_loop().time() + async with httpx.AsyncClient() as client: + while (asyncio.get_event_loop().time() - start_time) < timeout: + try: + response = await client.get(f"{self.API_URL}/droplets/{droplet_id}", headers=self.headers) + response.raise_for_status() + droplet = response.json()["droplet"] + + networks = droplet.get("networks", {}).get("v4", []) + for nw in networks: + if nw["type"] == "public": + return nw["ip_address"] + + except Exception as e: + logger.warning(f"Error polling droplet {droplet_id}: {e}") + + await asyncio.sleep(10) + return None + + async def terminate_node(self, droplet_id: int): + """ + Destroy the inference node to stop billing. + """ + async with httpx.AsyncClient() as client: + try: + response = await client.delete(f"{self.API_URL}/droplets/{droplet_id}", headers=self.headers) + response.raise_for_status() + logger.info(f"Inference node {droplet_id} termination requested.") + return True + except Exception as e: + logger.error(f"Failed to terminate droplet {droplet_id}: {e}") + return False + +infrastructure_service = InfrastructureService() diff --git a/backend/services/memory_service.py b/backend/services/memory_service.py new file mode 100644 index 0000000000000000000000000000000000000000..7150de2dd1ecf9a919925200c1d940debad44593 --- /dev/null +++ b/backend/services/memory_service.py @@ -0,0 +1,174 @@ +import logging +from typing import List, Dict, Any, Optional +from services.supabase_service import supabase +from services.embedding_service import embedding_service + +logger = logging.getLogger("uvicorn") + +class MemoryService: + """ + Handles vectorized long-term memory for Aubm projects. + Allows agents to retrieve context from past projects and approved work. + """ + + async def save_memory( + self, + project_id: str, + content: str, + task_id: Optional[str] = None, + memory_type: str = "approved_output", + metadata: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Vectorizes content and saves it to project_memory. + """ + try: + if not content or len(content.strip()) < 10: + return False + + embedding = await embedding_service.get_embedding(content) + + data = { + "project_id": project_id, + "task_id": task_id, + "content": content, + "embedding": embedding, + "memory_type": memory_type, + "metadata": metadata or {} + } + + result = supabase.table("project_memory").insert(data).execute() + return len(result.data) > 0 + except Exception as e: + logger.error(f"Failed to save memory: {e}") + return False + + async def search_memory( + self, + query: str, + limit: int = 5, + threshold: float = 0.7, + project_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + Performs semantic search across project memory. + If project_id is provided, filters memory to that project only (short-term). + If project_id is None, searches cross-project (long-term). + """ + try: + query_embedding = await embedding_service.get_embedding(query) + + # Use the match_project_memory RPC function defined in add_vector_memory.sql + rpc_params = { + "query_embedding": query_embedding, + "match_threshold": threshold, + "match_count": limit, + } + + if project_id: + rpc_params["filter_project_id"] = project_id + + result = supabase.rpc("match_project_memory", rpc_params).execute() + return result.data or [] + except Exception as e: + logger.error(f"Failed to search memory: {e}") + return [] + + async def index_task_output(self, task: Dict[str, Any]) -> bool: + """ + Specialized indexer for approved task outputs. + """ + output_data = task.get("output_data") + if not output_data: + return False + + # Extract meaningful text from output + content = "" + if isinstance(output_data, str): + content = output_data + elif isinstance(output_data, dict): + # Try to get the primary content + content = ( + output_data.get("data") or + output_data.get("strategicConclusion") or + output_data.get("raw_output") or + str(output_data) + ) + + if not content: + return False + + return await self.save_memory( + project_id=task.get("project_id"), + task_id=task.get("id"), + content=str(content), + memory_type="approved_output", + metadata={ + "task_title": task.get("title"), + "agent_id": task.get("assigned_agent_id") + } + ) + + async def analyze_rejection(self, task_id: str, feedback: Optional[str] = None): + """ + Analyzes a task rejection to generate a 'Self-Optimization Lesson'. + Triggered when a human rejects an agent's output. + """ + try: + # 1. Fetch task and its failed output + task_res = supabase.table("tasks").select("*, projects(name, description)").eq("id", task_id).single().execute() + if not task_res.data: + return + + task = task_res.data + output = task.get("output_data") or {} + + # 2. Get an analyst agent + from agents.agent_factory import AgentFactory + from services.llm_config import getDefaultProvider, getDefaultModel + + provider = getDefaultProvider() + model = getDefaultModel(provider) + + analyst = AgentFactory.get_agent( + provider=provider, + name="Optimization Analyst", + role="Self-Optimization Expert", + model=model, + system_prompt="You analyze task rejections. Your goal is to produce a single, concise 'Lesson Learned' that the next agent should follow to avoid repeating the mistake. Focus on the core reason for rejection." + ) + + # 3. Construct prompt for analysis + analysis_prompt = f""" + TASK: {task.get('title')} + DESCRIPTION: {task.get('description')} + + REJECTED OUTPUT: + {str(output)[:2000]} + + HUMAN FEEDBACK: {feedback or "No explicit feedback provided, but the output did not meet quality standards."} + + Provide a concise 'LESSON LEARNED' for the next agent. Start with 'Next time, you must...' + """ + + result = await analyst.run(analysis_prompt, []) + lesson_text = result.get("raw_output") or result.get("data") + + if lesson_text: + await self.save_memory( + project_id=task.get("project_id"), + task_id=task_id, + content=f"Optimization Lesson for '{task.get('title')}': {lesson_text}", + memory_type="self_optimization_lesson", + metadata={ + "original_task_id": task_id, + "was_rejected": True, + "feedback": feedback + } + ) + logger.info(f"Saved self-optimization lesson for task {task_id}") + + except Exception as e: + logger.error(f"Failed to analyze rejection for task {task_id}: {e}") + +memory_service = MemoryService() diff --git a/backend/services/orchestrator_service.py b/backend/services/orchestrator_service.py new file mode 100644 index 0000000000000000000000000000000000000000..502aeefe28392267ae705592d691ae2a0fb5e55f --- /dev/null +++ b/backend/services/orchestrator_service.py @@ -0,0 +1,1059 @@ +from services.supabase_service import supabase +from agents.agent_factory import AgentFactory +import json +import logging +import re +from services.config import settings +from services.agent_runner_service import AgentRunnerService +from services.audit_service import audit_service +from services.evidence_service import evidence_service +from services.output_quality import clean_report_text, dedupe_lines, filter_report_sections, validate_output + +logger = logging.getLogger("uvicorn") + +NOISY_REPORT_KEYS = { + "raw_text", + "sampleBackendCode", + "sampleUploadSnippet", + "sampleSearchEndpoint", + "sampleRedisCartHelper", + "sampleWebhookHandler", + "sampleStateMachine", + "repositoryStructure", + "wireframes", + "dataModel", + "userStories", +} + +def _humanize_key(key: str) -> str: + return key.replace("_", " ").replace("-", " ").strip().title() + +def _format_value_for_report(value, level: int = 0) -> list[str]: + if value is None: + return ["Not specified."] + + if isinstance(value, (str, int, float, bool)): + return [str(value)] + + if isinstance(value, list): + lines: list[str] = [] + for item in value: + if isinstance(item, dict): + item_lines = _format_value_for_report(item, level + 1) + if item_lines: + lines.append(f"- {item_lines[0]}") + lines.extend(f" {line}" for line in item_lines[1:]) + elif isinstance(item, list): + nested = _format_value_for_report(item, level + 1) + lines.extend(f"- {line}" for line in nested) + else: + lines.append(f"- {item}") + return lines or ["No items."] + + if isinstance(value, dict): + lines: list[str] = [] + for key, item in value.items(): + if str(key) in NOISY_REPORT_KEYS: + continue + title = _humanize_key(str(key)) + if isinstance(item, dict): + lines.append(f"{title}:") + lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1)) + elif isinstance(item, list): + lines.append(f"{title}:") + lines.extend(f" {line}" for line in _format_value_for_report(item, level + 1)) + else: + lines.append(f"{title}: {item}") + return lines or ["No details."] + + return [str(value)] + + +def _extract_json_payload(text: str): + if not text: + return None + + stripped = text.strip() + + # 1. Try standard block extraction + if stripped.startswith("```"): + cleaned = stripped.strip("`") + if cleaned.lower().startswith("json"): + cleaned = cleaned[4:].strip() + try: + return json.loads(cleaned) + except Exception: + pass # Fallback to regex + + # 2. Try direct parsing + try: + return json.loads(stripped) + except Exception: + pass + + # 3. Robust Regex Search (find content between first { and last }) + # This is the "Repair Layer" for noisy LLM outputs + try: + # Search for anything starting with { and ending with } + # across multiple lines + match = re.search(r'(\{.*\})', stripped, re.DOTALL) + if match: + return json.loads(match.group(1)) + except Exception: + pass + + # 4. Specific Markdown Block Search + match = re.search(r"```json\s*(.*?)\s*```", text, re.IGNORECASE | re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except Exception: + pass + + return None + +def _format_output_for_report(output_data) -> str: + if not output_data: + return "No approved output was saved for this task." + + if isinstance(output_data, dict): + primary = ( + output_data.get("data") + or output_data.get("final") + or output_data.get("raw_output") + or output_data + ) + else: + primary = output_data + + if isinstance(primary, str): + parsed = _extract_json_payload(primary) + if parsed is not None: + return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(parsed)))) + return clean_report_text(dedupe_lines(primary)) + + return clean_report_text(dedupe_lines("\n".join(_format_value_for_report(primary)))) + + +def _is_empty_curated_text(text: str) -> bool: + normalized = (text or "").strip().lower() + return normalized in { + "", + "no approved output was saved for this task.", + "{}", + "[]", + } + + +def _is_empty_report_variant(text: str | None) -> bool: + normalized = clean_report_text(dedupe_lines(text or "")).strip() + content_words = re.findall(r"[A-Za-z0-9_]+", normalized) + lower = normalized.lower() + return ( + len(content_words) < 20 + or lower in {"{}", "[]", "null", "none", "no details.", "not specified."} + or lower.startswith("```") + ) + + +def _format_conclusion_payload(data: dict) -> str: + conclusion = data.get("strategicConclusion") or data.get("conclusion") or data.get("content") or "" + next_steps = data.get("nextSteps") or data.get("next_steps") or [] + + lines: list[str] = [] + if isinstance(conclusion, str) and conclusion.strip(): + lines.append(conclusion.strip()) + + usable_steps = [ + step.strip() + for step in next_steps + if isinstance(step, str) and step.strip() + ] if isinstance(next_steps, list) else [] + + if usable_steps: + lines.append("") + lines.append("Next steps:") + for step in usable_steps[:5]: + lines.append(f"- {step}") + + return "\n".join(lines).strip() or "\n".join(_format_value_for_report(data)) + + +def _has_usable_output(output_data) -> bool: + if not output_data: + return False + if isinstance(output_data, dict): + if output_data.get("error"): + return False + primary = output_data.get("data") + if primary in (None, "", [], {}): + return False + return True + +def _output_text(output_data) -> str: + return _format_output_for_report(output_data).lower() + +def _build_report_charts(tasks: list[dict]) -> dict: + total = len(tasks) + done = sum(1 for task in tasks if task.get("status") == "done") + failed = sum(1 for task in tasks if task.get("status") == "failed") + pending = max(total - done - failed, 0) + + priority_counts: dict[str, int] = {} + for task in tasks: + priority = str(task.get("priority") if task.get("priority") is not None else 0) + priority_counts[priority] = priority_counts.get(priority, 0) + 1 + + categories = { + "Market": ("market", "competitor", "customer", "segment", "demand"), + "Product": ("product", "mvp", "feature", "design", "scope"), + "Revenue": ("revenue", "price", "pricing", "margin", "commission"), + "Operations": ("operation", "process", "logistic", "support", "fulfillment"), + "Risk": ("risk", "threat", "failure", "weak", "mitigation") + } + category_counts = {name: 0 for name in categories} + risk_mentions = 0 + + for task in tasks: + text = f"{task.get('title', '')} {task.get('description', '')} {_output_text(task.get('output_data'))}".lower() + risk_mentions += sum(text.count(term) for term in categories["Risk"]) + for category, terms in categories.items(): + if any(term in text for term in terms): + category_counts[category] += 1 + + opportunity_score = 85 if total and done == total else round((done / total) * 85) if total else 0 + risk_score = min(95, 35 + risk_mentions * 3) + readiness_score = round((done / total) * 100) if total else 0 + + return { + "status": [ + {"label": "Approved", "value": done}, + {"label": "Pending", "value": pending}, + {"label": "Failed", "value": failed} + ], + "priorities": [ + {"label": f"Priority {key}", "value": value} + for key, value in sorted(priority_counts.items(), key=lambda item: int(item[0]) if item[0].isdigit() else 0, reverse=True) + ], + "categories": [ + {"label": label, "value": value} + for label, value in category_counts.items() + ], + "scores": [ + {"label": "Readiness", "value": readiness_score}, + {"label": "Opportunity", "value": opportunity_score}, + {"label": "Risk", "value": risk_score} + ] + } + +def _format_chart_rows(title: str, rows: list[dict]) -> list[str]: + if not rows: + return [f"### {title}", "No data available.", ""] + + lines = [f"### {title}"] + lines.extend(f"- {row['label']}: {row['value']}" for row in rows) + lines.append("") + return lines + +def _format_execution_summary(charts: dict, total_tasks: int, kept_task_count: int, excluded_count: int) -> list[str]: + lines = [ + f"- Total tasks: {total_tasks}", + f"- Included outputs: {kept_task_count}", + f"- Excluded outputs: {excluded_count}", + "", + ] + lines.extend(_format_chart_rows("Scores", charts.get("scores", []))) + lines.extend(_format_chart_rows("Task Categories", charts.get("categories", []))) + lines.extend(_format_chart_rows("Priorities", charts.get("priorities", []))) + return lines + + + + + +async def _format_evidence_summary(project_id: str, claims: list[dict]) -> list[str]: + if not claims: + return [] + + # Get semantically merged claims for the "Strategic Findings" section + merged_claims = await evidence_service.merge_project_claims(project_id, threshold=0.88) + summary = evidence_service.summarize_claims(claims) + + lines = [ + "## Strategic Findings & Evidence", + f"The analysis has consolidated **{summary['claim_count']}** unique data points into **{len(merged_claims)}** strategic findings.", + f"Source coverage: **{summary['source_coverage']:.0%}** (Claims backed by external evidence).", + "", + "### Key Consolidated Findings", + ] + + # Show merged claims with their confidence and sources + for claim in merged_claims[:15]: + text = claim.get("claim_text") + entity = claim.get("entity_name") + source = claim.get("source_url") + confidence = claim.get("confidence", "unknown") + merged_count = claim.get("merged_count", 1) + + prefix = f"**[{entity}]** " if entity else "" + source_suffix = f" [Source: {source}]" if source else " [Internal Analysis]" + repetition_suffix = f" (Verified by {merged_count} sources)" if merged_count > 1 else "" + + lines.append(f"- {prefix}{text}{repetition_suffix}{source_suffix}") + + if summary["by_entity"]: + lines.append("") + lines.append("### Entity Analysis Coverage") + for entity, count in list(summary["by_entity"].items())[:8]: + lines.append(f"- **{entity}**: {count} supporting claims identified.") + + lines.append("") + return lines + +REPORT_VARIANTS = { + "full": { + "title": "Final Report", + "agent_terms": [], + "fallback_heading": "Approved Work Summary", + "prompt": "" + }, + "brief": { + "title": "Short Brief", + "agent_terms": ["brief", "summary", "writer"], + "fallback_heading": "Short Brief", + "prompt": ( + "Create a concise executive brief from the approved project work. " + "Use plain English, no JSON, no code blocks. Include: objective, main findings, recommended next steps, and key risks. " + "Keep it short and decision-oriented. Do not invent entities, metrics, or placeholders." + ) + }, + "pessimistic": { + "title": "Pessimistic Analysis", + "agent_terms": ["pessimistic", "risk", "critic", "reviewer"], + "fallback_heading": "Pessimistic Analysis", + "prompt": ( + "Create a skeptical, downside-focused analysis from the approved project work. " + "Use plain English, no JSON, no code blocks. Focus on what can fail, weak assumptions, operational risks, market risks, " + "financial risks, execution gaps, and mitigation priorities. Do not invent entities, metrics, or placeholders." + ) + } +} + +class OrchestratorService: + """ + Handles complex multi-agent workflows like Debates and Peer Reviews. + """ + + async def run_debate(self, task_id: str, agent_a_id: str, agent_b_id: str): + """ + Executes a debate between two agents for a specific task. + """ + try: + # 1. Fetch task and agents + task = supabase.table("tasks").select("*").eq("id", task_id).single().execute().data + agent_a_data = supabase.table("agents").select("*").eq("id", agent_a_id).single().execute().data + agent_b_data = supabase.table("agents").select("*").eq("id", agent_b_id).single().execute().data + + if not task or not agent_a_data or not agent_b_data: + raise ValueError("Task or agents not found for debate.") + + # Update status to in_progress + supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="debate_started", + agent_id=agent_a_id, + task_id=task_id, + metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id")}, + ) + + # 2. Agent A generates initial response + initial_res, _ = await AgentRunnerService.run_agent_task( + task, + agent_a_data, + start_action="debate_initial_start", + start_content=f"Debate Step 1: {agent_a_data['name']} generating initial proposal.", + complete_action="debate_initial_complete", + update_task=False + ) + + # 3. Agent B reviews and critiques + # We temporarily modify the task description for this run + task_critique = task.copy() + task_critique["description"] = f"Review the following output for the task: '{task['description']}'. Provide constructive critique and identify errors.\n\nOutput: {json.dumps(initial_res['data'])}" + + critique_res, _ = await AgentRunnerService.run_agent_task( + task_critique, + agent_b_data, + start_action="debate_critique_start", + start_content=f"Debate Step 2: {agent_b_data['name']} critiquing the proposal.", + complete_action="debate_critique_complete", + update_task=False + ) + + # 4. Agent A refines based on critique + task_refinement = task.copy() + task_refinement["description"] = f"Refine your initial output for the task: '{task['description']}' based on this critique: {json.dumps(critique_res['data'])}" + + final_res, _ = await AgentRunnerService.run_agent_task( + task_refinement, + agent_a_data, + start_action="debate_refinement_start", + start_content=f"Debate Step 3: {agent_a_data['name']} refining proposal based on feedback.", + complete_action="debate_refinement_complete", + update_task=False + ) + + # 5. Save consolidated result and mark for approval + consolidated_output = { + "agent_name": agent_a_data["name"], + "provider": agent_a_data["api_provider"], + "model": agent_a_data["model"], + "is_debate": True, + "data": final_res["data"], + "debate_history": { + "initial": initial_res["data"], + "critique": critique_res["data"], + "final": final_res["data"] + } + } + + supabase.table("tasks").update({ + "status": "awaiting_approval", + "output_data": consolidated_output + }).eq("id", task_id).execute() + claims_count = await evidence_service.replace_task_claims(task, consolidated_output) + await audit_service.log_action( + user_id=None, + action="debate_completed", + agent_id=agent_a_id, + task_id=task_id, + metadata={"agent_b_id": agent_b_id, "project_id": task.get("project_id"), "claims_count": claims_count}, + ) + + logger.info(f"Debate completed for task {task_id}") + + except Exception as e: + logger.error(f"Debate failed: {str(e)}") + supabase.table("tasks").update({ + "status": "failed", + "output_data": {"error": str(e)} + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="debate_failed", + agent_id=agent_a_id, + task_id=task_id, + metadata={"agent_b_id": agent_b_id, "error": str(e)}, + ) + + # LOG ERROR TO AGENT CONSOLE + supabase.table("agent_logs").insert({ + "task_id": task_id, + "action": "debate_failed", + "content": f"DEBATE ERROR: {str(e)}" + }).execute() + + async def run_project(self, project_id: str): + """ + Runs queued tasks in a project sequentially. Unassigned tasks are assigned + to the first available project-owner or global agent. + """ + project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data + if not project: + raise ValueError(f"Project not found: {project_id}") + + owner_id = project.get("owner_id") + tasks = ( + supabase.table("tasks") + .select("*") + .eq("project_id", project_id) + .in_("status", ["todo", "failed", "queued"]) + .order("priority", desc=True) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + + # Check if ANY tasks exist for this project (regardless of status) to avoid re-decomposing + all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute() + has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0 + + # Automatic Decomposition: Only if no tasks exist AT ALL + if not has_any_tasks: + logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition.") + await self.decompose_project(project_id) + # Re-fetch tasks after decomposition + tasks = ( + supabase.table("tasks") + .select("*") + .eq("project_id", project_id) + .in_("status", ["todo", "failed", "queued"]) + .order("priority", desc=True) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + + agents = supabase.table("agents").select("*").execute().data or [] + available_agents = [ + agent for agent in agents + if agent.get("user_id") in (None, owner_id) or agent.get("id") in {t.get("assigned_agent_id") for t in tasks if t.get("assigned_agent_id")} + ] + + completed = 0 + failed = 0 + + for task in tasks: + try: + agent_data = self._resolve_agent(task, available_agents) + if not agent_data: + raise ValueError("No available agent for task") + + if not task.get("assigned_agent_id"): + supabase.table("tasks").update({ + "assigned_agent_id": agent_data["id"] + }).eq("id", task["id"]).execute() + task["assigned_agent_id"] = agent_data["id"] + + await self._run_task(task, agent_data) + completed += 1 + except Exception as exc: + failed += 1 + logger.error(f"Project orchestration task failed: {str(exc)}") + supabase.table("tasks").update({ + "status": "failed", + "output_data": {"error": str(exc)} + }).eq("id", task["id"]).execute() + + return { + "project_id": project_id, + "queued_tasks": len(tasks), + "completed": completed, + "failed": failed, + } + + async def queue_project(self, project_id: str): + """ + Assigns available agents and queues runnable project tasks for worker execution. + """ + from services.task_queue import TaskQueueService + + project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data + if not project: + raise ValueError(f"Project not found: {project_id}") + if project.get("status") == "completed": + raise ValueError("Completed projects are locked and cannot be modified.") + + owner_id = project.get("owner_id") + tasks = ( + supabase.table("tasks") + .select("*") + .eq("project_id", project_id) + .in_("status", ["todo", "failed", "queued"]) + .order("priority", desc=True) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + + all_tasks_res = supabase.table("tasks").select("id", count="exact").eq("project_id", project_id).limit(1).execute() + has_any_tasks = all_tasks_res.count > 0 if all_tasks_res.count is not None else len(all_tasks_res.data) > 0 + + if not has_any_tasks: + logger.info(f"No tasks found for project {project_id}. Triggering auto-decomposition before queueing.") + await self.decompose_project(project_id) + tasks = ( + supabase.table("tasks") + .select("*") + .eq("project_id", project_id) + .in_("status", ["todo", "failed", "queued"]) + .order("priority", desc=True) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + + agents = supabase.table("agents").select("*").execute().data or [] + assigned_ids = {t.get("assigned_agent_id") for t in tasks if t.get("assigned_agent_id")} + available_agents = [ + agent for agent in agents + if agent.get("user_id") in (None, owner_id) or agent.get("id") in assigned_ids + ] + + queued = 0 + failed = 0 + skipped = 0 + + for task in tasks: + try: + agent_data = self._resolve_agent(task, available_agents) + if not agent_data: + raise ValueError("No available agent for task") + + if not task.get("assigned_agent_id"): + supabase.table("tasks").update({ + "assigned_agent_id": agent_data["id"] + }).eq("id", task["id"]).execute() + + result = await TaskQueueService.queue_task(task["id"]) + if result and result.data: + queued += 1 + else: + skipped += 1 + except Exception as exc: + failed += 1 + logger.error(f"Project queueing task failed: {str(exc)}") + supabase.table("tasks").update({ + "status": "failed", + "last_error": str(exc), + "output_data": {"error": str(exc)} + }).eq("id", task["id"]).execute() + await audit_service.log_action( + user_id=owner_id, + action="task_queue_failed", + task_id=task.get("id"), + metadata={"project_id": project_id, "error": str(exc)}, + ) + + await audit_service.log_action( + user_id=owner_id, + action="project_queued", + metadata={ + "project_id": project_id, + "queued_tasks": queued, + "failed": failed, + "skipped": skipped, + }, + ) + + return { + "project_id": project_id, + "queued_tasks": queued, + "failed": failed, + "skipped": skipped, + "mode": "queue", + } + + def _select_report_agent(self, project: dict, variant: str): + config = REPORT_VARIANTS.get(variant, REPORT_VARIANTS["full"]) + terms = config["agent_terms"] + if not terms: + return None + + owner_id = project.get("owner_id") + agents = supabase.table("agents").select("*").execute().data or [] + available_agents = [ + agent for agent in agents + if agent.get("user_id") in (None, owner_id) + ] + + return next( + ( + agent for agent in available_agents + if any(term in f"{agent.get('name', '')} {agent.get('role', '')}".lower() for term in terms) + ), + available_agents[0] if available_agents else None + ) + + async def _generate_report_variant_with_agent(self, project: dict, report: str, variant: str): + agent_data = self._select_report_agent(project, variant) + if not agent_data: + return None + + config = REPORT_VARIANTS[variant] + agent = AgentFactory.get_agent( + provider=agent_data["api_provider"], + name=agent_data["name"], + role=agent_data["role"], + model=agent_data["model"], + system_prompt=agent_data.get("system_prompt") + ) + result = await agent.run(f"{config['prompt']}\n\nApproved project material:\n{report}", []) + if result.get("status") == "error": + raise RuntimeError(result.get("error") or "Report agent returned an error.") + + data = result.get("data") + if isinstance(data, dict): + for key in ("brief", "analysis", "report", "summary", "content"): + value = data.get(key) + if isinstance(value, str) and not _is_empty_report_variant(value): + return value + formatted = "\n".join(_format_value_for_report(data)) + return None if _is_empty_report_variant(formatted) else formatted + if isinstance(data, str): + return None if _is_empty_report_variant(data) else data + raw_output = result.get("raw_output") + return None if _is_empty_report_variant(raw_output) else raw_output + + def _build_fallback_variant(self, project: dict, tasks: list[dict], variant: str): + config = REPORT_VARIANTS[variant] + lines = [ + f"# {config['title']}: {project['name']}", + "", + "## Project Brief", + project.get("description") or "No project description provided.", + "", + f"## {config['fallback_heading']}" + ] + + if variant == "brief": + lines.extend([ + f"All {len(tasks)} approved tasks have been consolidated.", + "The project is ready for decision review based on the approved task outputs.", + "", + "Recommended next steps:", + "- Validate the highest-impact assumptions with real users or customers.", + "- Prioritize the smallest launch scope that proves demand.", + "- Convert approved outputs into an execution backlog with owners and dates." + ]) + return "\n".join(lines) + + if variant == "pessimistic": + lines.extend([ + "This project can still fail even with all tasks approved.", + "", + "Primary downside risks:", + "- Approved task outputs may be internally consistent but unvalidated by the market.", + "- Revenue, conversion, operational, and adoption assumptions may be too optimistic.", + "- Execution scope can expand faster than the team can deliver.", + "- Competitors can respond with pricing, distribution, or trust advantages.", + "", + "Mitigation priorities:", + "- Validate demand before building broad feature scope.", + "- Stress-test unit economics and support costs.", + "- Define kill criteria before committing more resources." + ]) + return "\n".join(lines) + + return None + + def _quality_approved_tasks(self, tasks: list[dict], project: dict) -> tuple[list[dict], list[dict]]: + approved: list[dict] = [] + excluded: list[dict] = [] + for task in tasks: + output_data = task.get("output_data") or {} + if not _has_usable_output(output_data): + excluded.append({ + "title": task.get("title", "Untitled task"), + "reasons": ["Task has no usable approved output."] + }) + continue + task_with_project = {**task, "project": project} + quality_review = output_data.get("quality_review") if isinstance(output_data, dict) else None + if not quality_review and isinstance(output_data, dict): + quality_review = validate_output(task_with_project, output_data) + if quality_review and not quality_review.get("approved", False): + excluded.append({ + "title": task.get("title", "Untitled task"), + "reasons": quality_review.get("fail_reasons") or ["Failed quality review."] + }) + continue + approved.append(task) + return approved, excluded + + def _curate_task_output(self, output_data) -> tuple[str, list[str]]: + text = _format_output_for_report(output_data) + text = clean_report_text(dedupe_lines(text)) + text, excluded_lines = filter_report_sections(text) + return text or "No approved output was saved for this task.", excluded_lines + + async def build_final_report(self, project_id: str, variant: str = "full"): + variant = variant if variant in REPORT_VARIANTS else "full" + project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data + if not project: + raise ValueError(f"Project not found: {project_id}") + + tasks = ( + supabase.table("tasks") + .select("title,description,status,priority,output_data,created_at") + .eq("project_id", project_id) + .order("priority", desc=True) + .order("created_at", desc=False) + .execute() + .data + or [] + ) + + if not tasks: + raise ValueError("Project has no tasks to summarize.") + + incomplete = [task for task in tasks if task.get("status") != "done"] + if incomplete: + raise ValueError(f"Final report is available after all tasks are approved. Pending tasks: {len(incomplete)}") + + curated_tasks, excluded_tasks = self._quality_approved_tasks(tasks, project) + if not curated_tasks: + # Fallback: if no tasks pass the strict quality review, include all 'done' tasks + # so the user can at least see a draft report. + logger.warning(f"Project {project_id}: No tasks passed quality review. Falling back to all tasks.") + curated_tasks = tasks + + # Load raw claims for statistics, and we will use semantic merging inside _format_evidence_summary + all_raw_claims = evidence_service.load_project_claims(project_id) + merged_claims = await evidence_service.merge_project_claims(project_id) + + # 0. Header and Description + report_title = REPORT_VARIANTS[variant]["title"] + lines = [ + f"# {report_title}: {project['name']}", + "", + "## Project Overview", + project.get("description") or "No description provided.", + "" + ] + + # Add Context if exists + if project.get("context"): + lines.extend(["## Context", project["context"], ""]) + + approved_work_lines = ["## Approved Work Summary", ""] + + report_exclusions: list[str] = [] + included_tasks: list[dict] = [] + kept_task_count = 0 + for task in curated_tasks: + curated_text, excluded_lines = self._curate_task_output(task.get("output_data")) + report_exclusions.extend(excluded_lines) + if _is_empty_curated_text(curated_text): + excluded_tasks.append({ + "title": task.get("title", "Untitled task"), + "reasons": ["Task output became empty after quality filtering."] + }) + continue + kept_task_count += 1 + included_tasks.append(task) + approved_work_lines.extend([ + f"### {kept_task_count}. {task['title']}", + task.get("description") or "No task description provided.", + "", + curated_text, + "" + ]) + + charts = _build_report_charts(included_tasks) + lines.extend(["## Execution Summary", ""]) + lines.extend(_format_execution_summary(charts, len(tasks), kept_task_count, len(excluded_tasks))) + + # New Evidence-Aware Strategic Findings Section + evidence_section = await _format_evidence_summary(project_id, all_raw_claims) + lines.extend(evidence_section) + + lines.extend(approved_work_lines) + + if excluded_tasks or report_exclusions: + lines.extend(["## Excluded Content", ""]) + for excluded in excluded_tasks: + lines.append(f"- Excluded task output: {excluded['title']} ({'; '.join(excluded['reasons'])})") + for excluded_line in list(dict.fromkeys(report_exclusions))[:10]: + if excluded_line: + lines.append(f"- {excluded_line}") + lines.append("") + + # Final Conclusion Generation + conclusion = ( + "Based on the approved task outputs, the project has successfully established a foundational framework. " + "The key findings suggest a viable path forward by focusing on the identified entry wedge and " + "mitigating primary risks through phased execution." + ) + + if variant == "full": + try: + # Use the 'Brief Writer' or any available agent to summarize a conclusion + agent_data = self._select_report_agent(project, "brief") + if agent_data: + agent = AgentFactory.get_agent( + provider=agent_data["api_provider"], + name=agent_data["name"], + role=agent_data["role"], + model=agent_data["model"], + system_prompt=( + "You are a Senior Strategic Consultant. Your goal is to write a comprehensive, " + "professional strategic conclusion for a project report based on approved work. " + "Synthesize the findings, highlight critical success factors, identify remaining " + "operational or market risks, and provide 3-5 high-impact, actionable next steps. " + "The tone should be executive, insightful, and strictly based on provided facts. " + "Avoid generic filler or unsupported placeholders." + ) + ) + report_so_far = "\n".join(lines) + # Feed the strategic conclusion agent with the consolidated findings for maximum accuracy + evidence_context = "\n".join(evidence_section) + res = await agent.run( + f"Project: {project['name']}\n" + f"Consolidated Strategic Findings:\n{evidence_context}\n\n" + f"Full Report Context:\n{report_so_far}\n\n" + "Task: Write a final strategic conclusion and 3-5 next steps based on the findings above.", + [] + ) + if res.get("status") != "error": + data = res.get("data") + if isinstance(data, str): + conclusion = data + elif isinstance(data, dict): + conclusion = _format_conclusion_payload(data) + except Exception as exc: + logger.warning(f"Failed to generate dynamic conclusion: {exc}") + + lines.extend([ + "## Strategic Conclusion", + conclusion, + "", + "## Completion Status", + f"{len(tasks)} tasks reached done status. {kept_task_count} task outputs were included in the final report. {len(excluded_tasks)} task outputs were excluded from the final report." + ]) + + supabase.table("projects").update({"status": "completed"}).eq("id", project_id).execute() + report = "\n".join(lines) + + if variant != "full": + try: + generated = await self._generate_report_variant_with_agent(project, report, variant) + fallback_report = self._build_fallback_variant(project, included_tasks or tasks, variant) + report = generated if not _is_empty_report_variant(generated) else fallback_report or report + except Exception as exc: + logger.warning(f"Report variant generation failed: {exc}") + report = self._build_fallback_variant(project, included_tasks or tasks, variant) or report + + await audit_service.log_action( + user_id=project.get("owner_id"), + action="final_report_generated", + metadata={ + "project_id": project_id, + "variant": variant, + "task_count": kept_task_count, + "excluded_task_count": len(excluded_tasks), + "normalized_claim_count": len(merged_claims), + }, + ) + + return { + "project_id": project_id, + "project_name": project["name"], + "task_count": kept_task_count, + "variant": variant, + "report": clean_report_text(dedupe_lines(report)), + "charts": charts, + "evidence": evidence_service.summarize_claims(merged_claims), + } + + async def decompose_project(self, project_id: str): + """ + Uses a Planner agent to decompose a project into discrete tasks. + """ + project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data + owner_id = project.get("owner_id") + + # Find a Planner agent, prioritizing Groq as requested + agents = supabase.table("agents").select("*").execute().data or [] + + # 1. Try to find an existing Groq Planner + planner_agent_data = next( + (a for a in agents if "Planner" in a["name"] and a.get("api_provider") == "groq"), + None + ) + + # 2. If not found, try any Planner + if not planner_agent_data: + planner_agent_data = next( + (a for a in agents if "Planner" in a["name"] and a.get("user_id") in (None, owner_id)), + next((a for a in agents if a.get("user_id") in (None, owner_id)), None) + ) + + # 3. If still no agent, or it's OpenAI but we want Groq, create a temporary one + if not planner_agent_data or (planner_agent_data.get("api_provider") == "openai" and not settings.OPENAI_API_KEY): + logger.info("Using default Groq Planner for decomposition.") + planner = AgentFactory.get_agent( + provider="groq", + name="System Planner", + role="Project Decomposer", + model="llama-3.3-70b-versatile", + system_prompt="You decompose goals into clear, ordered implementation tasks." + ) + else: + planner = AgentFactory.get_agent( + provider=planner_agent_data["api_provider"], + name=planner_agent_data["name"], + role=planner_agent_data["role"], + model=planner_agent_data["model"], + system_prompt=planner_agent_data.get("system_prompt") + ) + + prompt = f"""Decompose the following project into 3-5 clear, actionable implementation tasks. +Project Name: {project['name']} +Description: {project['description']} +Context: {project.get('context', 'None')} + +### Output Requirements: +You MUST return a valid JSON array of objects. Each object represents a task. +Do not include any conversational text, markdown formatting outside of the JSON, or explanations. + +### JSON Schema: +[ + {{ + "title": "string (The name of the task)", + "description": "string (Detailed instructions for the agent)", + "priority": "integer (1-5, where 5 is highest priority)" + }} +] + +IMPORTANT: Return a flat array. Do not wrap it in a parent 'tasks' object. +Do not use placeholder names or generic filler tasks. Every task title must be concrete and directly relevant to the stated project. +""" + + try: + result = await planner.run(prompt, []) + tasks_data = result.get("data") + + # Handle common LLM wrapping patterns + if isinstance(tasks_data, dict): + if "tasks" in tasks_data and isinstance(tasks_data["tasks"], list): + tasks_data = tasks_data["tasks"] + else: + tasks_data = [tasks_data] + + if not isinstance(tasks_data, list): + raise ValueError(f"Agent returned invalid format: {type(tasks_data)}. Expected list or dict.") + + # Filter out invalid tasks + valid_tasks = [ + t for t in tasks_data + if isinstance(t, dict) and t.get("title") + ] + + if not valid_tasks: + raise ValueError("No valid tasks extracted from agent output.") + + # Insert tasks + from .project_service import project_service + await project_service.add_tasks_to_project(project_id, valid_tasks) + await audit_service.log_action( + user_id=owner_id, + action="project_decomposed", + metadata={"project_id": project_id, "task_count": len(valid_tasks)}, + ) + logger.info(f"Auto-decomposed project {project_id} into {len(valid_tasks)} tasks.") + except Exception as e: + logger.error(f"Project decomposition failed: {e}") + + def _resolve_agent(self, task: dict, available_agents: list[dict]): + assigned_agent_id = task.get("assigned_agent_id") + if assigned_agent_id: + return next((agent for agent in available_agents if agent["id"] == assigned_agent_id), None) + return available_agents[0] if available_agents else None + + async def _run_task(self, task: dict, agent_data: dict): + await AgentRunnerService.run_agent_task( + task, + agent_data, + start_action="orchestrator_execution_start", + start_content=f"Orchestrator assigned {agent_data['name']} to task: {task['title']}", + complete_action="orchestrator_execution_complete", + complete_content="Task completed and is awaiting approval." + ) + +orchestrator_service = OrchestratorService() diff --git a/backend/services/output_quality.py b/backend/services/output_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..9b202c90a024aeb87ee081992beafddff2e1e13b --- /dev/null +++ b/backend/services/output_quality.py @@ -0,0 +1,325 @@ +import json +import re +from collections import OrderedDict +from typing import Any +from services.task_schemas import schema_instructions_for_task, validate_task_schema + +PLACEHOLDER_PATTERNS = [ + r"\bCompetitor\s+[A-Z]\b", + r"\bDashboard\s+[A-Z]\b", + r"\bProduct\s+[A-Z]\b", + r"\bCompany\s+[A-Z]\b", + r"\bOur Company\b", +] + +GENERIC_FILLER_PATTERNS = [ + r"\bsustainable products?\b", + r"\bdigital marketing\b", + r"\bcustomer segments?\b", + r"\bdemographics\b", + r"\bpsychographics\b", + r"\bdistribution channels?\b", +] + +SENSITIVE_FACT_PATTERNS = [ + r"\bmarket share\b", + r"\brevenue\b", + r"\barr\b", + r"\bpricing\b", + r"\bprice\b", + r"\blatest release version\b", + r"\bprofit\b", +] + +RAW_DUMP_PATTERNS = [ + r"```(?:json)?", + r'"raw_text"\s*:', + r'"projectoverview"\s*:', + r'"projectoverview"\s*:', + r'"userstories"\s*:', + r'"datamodel"\s*:', +] + +LATAM_HINTS = [ + "mercadolibre", + "mercado libre", + "latam", + "latin america", + "argentina", + "mexico", + "brazil", + "brasil", + "chile", + "colombia", + "peru", + "uruguay", +] + +SEA_HINTS = [ + "indonesia", + "yogyakarta", + "bali", + "southeast asia", + "tokopedia", + "shopee", + "jakarta", +] + +STRICT_TASK_PATTERNS = [ + r"\bresearch\b", + r"\banaly[sz]e\b", + r"\banalysis\b", + r"\bcompetitor\b", + r"\bpricing\b", + r"\bmarket\b", + r"\baudit\b", + r"\breport\b", + r"\bcompare\b", +] + + +def _stringify_payload(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=True) + except Exception: + return str(value) + + +def build_quality_instructions(task: dict) -> str: + project_text = _project_text(task) + task_text = f"{task.get('title', '')}\n{task.get('description', '')}\n{project_text}".lower() + strict_mode = any(re.search(pattern, task_text, re.IGNORECASE) for pattern in STRICT_TASK_PATTERNS) + + base = [ + "Output quality rules:", + "- Never use placeholder names like Competitor A, Dashboard B, Product C, or Our Company.", + "- If a real named entity cannot be identified with confidence, return unknown instead of inventing one.", + "- Keep the output strictly within the requested scope.", + "- Stay aligned with the project's stated geography, competitors, and market context. Do not switch regions or industries unless the task explicitly requires it.", + "- Do not include generic filler sections that were not requested.", + "- Use clean UTF-8/ASCII friendly text. Do not output corrupted characters.", + "- Do not return raw JSON dumps, code blocks, repository scaffolds, or intermediate planning artifacts unless the task explicitly asks for them.", + ] + + if strict_mode: + base.extend( + [ + "- Return structured JSON where possible.", + "- For factual claims about competitors, products, pricing, versions, revenue, market share, or benchmarks, include source_url when available.", + "- Do not invent pricing, release versions, market share, revenue, ARR impact, or benchmarks.", + "- If a sensitive fact cannot be verified, omit it or mark it unknown.", + ] + ) + + schema_instructions = schema_instructions_for_task(task) + if schema_instructions: + base.extend(["", schema_instructions]) + + return "\n".join(base) + + +def _project_text(task: dict) -> str: + project = task.get("project") + if isinstance(project, dict): + return "\n".join( + str(project.get(key, "") or "") + for key in ("name", "description", "context") + ) + return str(task.get("project_context") or "") + + +def _contains_any(text: str, terms: list[str]) -> bool: + lowered = text.lower() + return any(term in lowered for term in terms) + + +def _looks_like_raw_dump(text: str) -> bool: + # Extremely relaxed check: Only flag as raw dump if it contains internal system keys + # that indicate it's a raw unformatted API response rather than a report. + internal_keys = [r'"raw_text"\s*:', r'"internal_status"\s*:', r'"debug_info"\s*:'] + if any(re.search(pattern, text, re.IGNORECASE) for pattern in internal_keys): + return True + + return False + + +def _is_context_drift(task_text: str, output_text: str) -> bool: + task_lower = task_text.lower() + output_lower = output_text.lower() + + if _contains_any(task_lower, LATAM_HINTS) and _contains_any(output_lower, SEA_HINTS): + return True + + return False + + +def validate_output(task: dict, result: dict) -> dict: + raw_text = _stringify_payload(result.get("raw_output")) + data_text = _stringify_payload(result.get("data")) + combined = "\n".join(part for part in [raw_text, data_text] if part).strip() + task_text = "\n".join( + [ + str(task.get("title", "") or ""), + str(task.get("description", "") or ""), + _project_text(task), + ] + ) + + fail_reasons: list[str] = [] + must_fix: list[str] = [] + placeholder_entities: list[str] = [] + unsupported_claims: list[str] = [] + duplicate_claims: list[str] = [] + encoding_issues: list[str] = [] + schema_review = validate_task_schema(task, result) + + if not combined: + fail_reasons.append("Empty output.") + + for pattern in PLACEHOLDER_PATTERNS: + matches = re.findall(pattern, combined, re.IGNORECASE) + placeholder_entities.extend(matches) + + if placeholder_entities: + # We don't add to fail_reasons anymore, just let the score reduction handle it + pass + + if "■" in combined: + encoding_issues.append("Found corrupted character '■'.") + + if encoding_issues: + fail_reasons.append("Output contains encoding corruption.") + must_fix.append("Remove corrupted characters and normalize text encoding.") + + if not schema_review["approved"]: + fail_reasons.extend(schema_review["fail_reasons"]) + must_fix.append("Regenerate the output as valid JSON matching the task schema.") + + if _looks_like_raw_dump(combined): + fail_reasons.append("Output contains raw JSON/code dump instead of a usable task result.") + must_fix.append("Convert intermediate JSON/code output into the requested final artifact.") + + if _is_context_drift(task_text, combined): + fail_reasons.append("Output drifted away from the project's stated geography or market context.") + must_fix.append("Regenerate the output using the project's explicit region, competitor set, and business context.") + + for pattern in GENERIC_FILLER_PATTERNS: + if re.search(pattern, combined, re.IGNORECASE): + unsupported_claims.append(pattern.replace("\\b", "").replace("?", "")) + + if unsupported_claims: + fail_reasons.append("Output contains generic filler outside the likely project scope.") + must_fix.append("Remove generic business-analysis filler not tied to the requested task.") + + has_source_url = bool(re.search(r"https?://", combined, re.IGNORECASE)) + for pattern in SENSITIVE_FACT_PATTERNS: + if re.search(pattern, combined, re.IGNORECASE) and not has_source_url: + unsupported_claims.append(f"Sensitive fact without source: {pattern}") + + if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims): + # We don't add to fail_reasons anymore, just let the score reduction handle it + pass + + normalized_lines = [] + seen_lines: set[str] = set() + for line in combined.splitlines(): + normalized = re.sub(r"\s+", " ", line).strip().lower() + if len(normalized) < 20: + continue + if normalized in seen_lines: + duplicate_claims.append(line.strip()) + else: + seen_lines.add(normalized) + normalized_lines.append(normalized) + + if duplicate_claims: + # Just let the score reduction handle it + pass + + score = 100 + if placeholder_entities: + score = min(score, 20) + if _looks_like_raw_dump(combined): + score = min(score, 20) + if _is_context_drift(task_text, combined): + score = min(score, 20) + if any(item.startswith("Sensitive fact without source:") for item in unsupported_claims): + score = min(score, 30) + if duplicate_claims: + score = min(score, 50) + if unsupported_claims and not any(item.startswith("Sensitive fact without source:") for item in unsupported_claims): + score = min(score, 60) + if encoding_issues: + score = min(score, 60) + if not schema_review["approved"]: + score = min(score, 15) + if not combined: + score = 0 + + approved = score >= 20 + return { + "approved": approved, + "score": score, + "fail_reasons": fail_reasons, + "must_fix": must_fix, + "duplicate_claims": list(OrderedDict.fromkeys(duplicate_claims))[:10], + "unsupported_claims": list(OrderedDict.fromkeys(unsupported_claims))[:10], + "placeholder_entities": list(OrderedDict.fromkeys(placeholder_entities))[:10], + "encoding_issues": encoding_issues, + "schema_review": schema_review, + } + + +def report_text_from_output(output_data: Any) -> str: + if not output_data: + return "" + if isinstance(output_data, dict): + primary = output_data.get("data") or output_data.get("final") or output_data.get("raw_output") or output_data + else: + primary = output_data + return _stringify_payload(primary) + + +def clean_report_text(text: str) -> str: + cleaned = text.replace("■", "-").replace("\u25A0", "-") + cleaned = re.sub(r"[ \t]+", " ", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +def dedupe_lines(text: str) -> str: + lines = text.splitlines() + kept: list[str] = [] + seen: set[str] = set() + for line in lines: + normalized = re.sub(r"\s+", " ", line).strip().lower() + if normalized and len(normalized) > 15 and normalized in seen: + continue + if normalized: + seen.add(normalized) + kept.append(line) + return "\n".join(kept).strip() + + +def filter_report_sections(text: str) -> tuple[str, list[str]]: + excluded: list[str] = [] + kept_lines: list[str] = [] + for line in text.splitlines(): + lowered = line.lower() + if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in PLACEHOLDER_PATTERNS): + excluded.append("Removed placeholder content.") + continue + if any(re.search(pattern, lowered, re.IGNORECASE) for pattern in GENERIC_FILLER_PATTERNS): + excluded.append("Removed generic filler outside the requested scope.") + continue + if _looks_like_raw_dump(line): + excluded.append("Removed raw JSON/code dump content.") + continue + kept_lines.append(line) + return "\n".join(kept_lines).strip(), excluded + + diff --git a/backend/services/project_service.py b/backend/services/project_service.py new file mode 100644 index 0000000000000000000000000000000000000000..939f811d70edd5039ae3fb746abd14f0e45cd1bc --- /dev/null +++ b/backend/services/project_service.py @@ -0,0 +1,52 @@ +from services.supabase_service import supabase +from typing import List, Dict, Any +import logging +from fastapi import HTTPException + +logger = logging.getLogger("uvicorn") + +class ProjectService: + """ + Handles the creation and management of projects and their constituent tasks. + """ + + @staticmethod + def get_project_or_404(project_id: str) -> Dict[str, Any]: + """Fetches a project or raises a 404 error.""" + project = supabase.table("projects").select("*").eq("id", project_id).single().execute().data + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + @staticmethod + def ensure_project_is_mutable(project_id: str) -> Dict[str, Any]: + """Verifies project existence and that it's not locked/completed.""" + project = ProjectService.get_project_or_404(project_id) + if project.get("status") == "completed": + raise HTTPException(status_code=409, detail="Completed projects are locked and cannot be modified.") + return project + + @staticmethod + async def create_project(title: str, description: str, user_id: str) -> Dict[str, Any]: + res = supabase.table("projects").insert({ + "title": title, + "description": description, + "user_id": user_id, + "status": "active" + }).execute() + return res.data[0] + + @staticmethod + async def add_tasks_to_project(project_id: str, tasks: List[Dict[str, Any]]): + """ + Adds a list of tasks to a project. + tasks: [{"title": "...", "description": "...", "assigned_agent_id": "..."}] + """ + formatted_tasks = [ + {**task, "project_id": project_id, "status": "todo"} + for task in tasks + ] + supabase.table("tasks").insert(formatted_tasks).execute() + logger.info(f"Added {len(tasks)} tasks to project {project_id}") + +project_service = ProjectService() diff --git a/backend/services/semantic_backprop.py b/backend/services/semantic_backprop.py new file mode 100644 index 0000000000000000000000000000000000000000..d163022dada1282fda5cb9b230c7f37dc94ce221 --- /dev/null +++ b/backend/services/semantic_backprop.py @@ -0,0 +1,104 @@ +import re +import logging +from typing import List, Dict, Any +from services.supabase_service import supabase + +logger = logging.getLogger("uvicorn") + +class SemanticBackpropService: + """ + Ensures numerical consistency across agent tasks by extracting 'Canonical Numbers' + from previous task outputs. + """ + + @staticmethod + async def get_project_context(project_id: str, current_task_id: str) -> str: + """ + Fetches and extracts canonical figures from all completed sibling tasks. + """ + try: + resp = supabase.table("tasks") \ + .select("title, output_data") \ + .eq("project_id", project_id) \ + .eq("status", "done") \ + .neq("id", current_task_id) \ + .execute() + + if not resp.data: + return "" + + canonical_blocks = [] + topic_blocks = [] + + for task in resp.data: + output = task.get("output_data") or {} + # Handle different output formats (raw string or dict with 'result') + result_text = "" + if isinstance(output, dict): + result_text = output.get("result", "") or output.get("raw_output", "") + elif isinstance(output, str): + result_text = output + + if not result_text: + continue + + # Extract financial and numerical lines + lines = result_text.splitlines() + financial_lines = [] + + # Keywords that often indicate a 'canonical' number + keywords = [ + "$", "%", "USD", "MRR", "ARR", "ROI", "cost", "budget", + "revenue", "price", "fee", "estimate", "total", "quota" + ] + + for line in lines: + if any(k.lower() in line.lower() for k in keywords): + if len(line.strip()) > 5: # Ignore very short lines + financial_lines.append(line.strip()) + + if financial_lines: + # De-duplicate similar lines + seen = set() + unique_fin = [] + for fl in financial_lines: + key = fl[:50] + if key not in seen: + seen.add(key) + unique_fin.append(fl) + + canonical_blocks.append( + f"Source Task: **{task['title']}**\n" + + "\n".join(f" • {fl}" for fl in unique_fin[:8]) + ) + + # Also track what topics were covered to avoid repetition + topic_blocks.append(f"- **{task['title']}**: (Covered in previous step)") + + if not canonical_blocks and not topic_blocks: + return "" + + context = "\n---\n" + if canonical_blocks: + context += ( + "### ⚠️ CANONICAL FIGURES — PREVIOUSLY ESTABLISHED\n" + "> **MANDATORY RULE**: The following numbers and figures were established by agents\n" + "> responsible for those domains. You MUST use these exact values if you reference them.\n" + "> DO NOT re-calculate or propose alternative values for these specific items.\n\n" + ) + context += "\n\n".join(canonical_blocks) + "\n\n" + + if topic_blocks: + context += ( + "### 📋 PREVIOUSLY COVERED TOPICS\n" + "> Do not repeat the analysis of these topics. Focus only on your specific task.\n" + ) + context += "\n".join(topic_blocks) + "\n" + + return context + + except Exception as e: + logger.error(f"Semantic Backprop failed: {e}") + return "" + +semantic_backprop = SemanticBackpropService() diff --git a/backend/services/supabase_service.py b/backend/services/supabase_service.py new file mode 100644 index 0000000000000000000000000000000000000000..378a01c5097d7337ed904640816338a6a4e51ee4 --- /dev/null +++ b/backend/services/supabase_service.py @@ -0,0 +1,13 @@ +from supabase import create_client, Client +from services.config import settings + +def get_supabase_client() -> Client: + """ + Initializes and returns a Supabase client. + """ + if not settings.SUPABASE_URL or not settings.SUPABASE_SERVICE_ROLE_KEY: + raise ValueError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment.") + + return create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_ROLE_KEY) + +supabase: Client = get_supabase_client() diff --git a/backend/services/task_queue.py b/backend/services/task_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..d95b2c1c36da081aaf4176cd426469a7bcf087c7 --- /dev/null +++ b/backend/services/task_queue.py @@ -0,0 +1,235 @@ +import logging +from datetime import datetime, timedelta, timezone +from typing import Any +from .supabase_service import supabase +from .audit_service import audit_service + +logger = logging.getLogger(__name__) + +class TaskQueueService: + @staticmethod + def _claim_next_queued_task_fallback(worker_id: str, lease_seconds: int = 300, max_attempts: int = 3): + """ + Fallback claim path when the RPC function is missing or broken in Supabase. + This is less strict than the DB-side atomic function, but it keeps single-worker + or low-contention setups operational. + """ + now = datetime.now(timezone.utc) + lease_expires_at = now + timedelta(seconds=max(lease_seconds, 1)) + + rows = ( + supabase.table("tasks") + .select("*") + .eq("status", "queued") + .order("priority", desc=True) + .order("created_at", desc=False) + .limit(25) + .execute() + .data + or [] + ) + + candidate = None + for row in rows: + attempts = int(row.get("queue_attempts") or 0) + if attempts >= max_attempts: + continue + + next_attempt_at = row.get("next_attempt_at") + if next_attempt_at and next_attempt_at > now.isoformat(): + continue + + current_lease = row.get("lease_expires_at") + if current_lease and current_lease > now.isoformat(): + continue + + candidate = row + break + + if not candidate: + return None + + attempts = int(candidate.get("queue_attempts") or 0) + result = ( + supabase.table("tasks") + .update({ + "status": "in_progress", + "queue_attempts": attempts + 1, + "leased_at": now.isoformat(), + "lease_expires_at": lease_expires_at.isoformat(), + "queue_worker_id": worker_id, + }) + .eq("id", candidate["id"]) + .eq("status", "queued") + .execute() + ) + + if result.data: + return result.data[0] + return None + + @staticmethod + async def queue_task(task_id: str): + """ + Marks a task as 'queued' in the database. + """ + try: + result = supabase.table("tasks").update({ + "status": "queued", + "queued_at": datetime.now(timezone.utc).isoformat(), + "leased_at": None, + "lease_expires_at": None, + "next_attempt_at": datetime.now(timezone.utc).isoformat(), + "queue_worker_id": None, + "queue_attempts": 0, + "last_error": None, + "output_data": None, + }).eq("id", task_id).execute() + return result + except Exception as e: + logger.error(f"Error queueing task {task_id}: {e}") + return None + + @staticmethod + async def claim_next_queued_task(worker_id: str, lease_seconds: int = 300, max_attempts: int = 3): + """ + Atomically claims the next available queued task. + """ + try: + result = supabase.rpc("claim_next_queued_task", { + "worker_id": worker_id, + "lease_seconds": lease_seconds, + "max_attempts": max_attempts, + }).execute() + + if result.data: + return result.data[0] + return None + except Exception as e: + logger.error(f"Error claiming next queued task via RPC, using fallback: {e}") + try: + return TaskQueueService._claim_next_queued_task_fallback( + worker_id, + lease_seconds=lease_seconds, + max_attempts=max_attempts, + ) + except Exception as fallback_error: + logger.error(f"Fallback queue claim also failed: {fallback_error}") + return None + + @staticmethod + async def get_next_queued_task(): + """ + Backwards-compatible alias for callers that do not pass a worker id. + """ + return await TaskQueueService.claim_next_queued_task("worker-legacy") + + @staticmethod + async def mark_in_progress(task_id: str): + """ + Marks a task as 'in_progress'. + """ + return supabase.table("tasks").update({"status": "in_progress"}).eq("id", task_id).execute() + + @staticmethod + async def clear_lease(task_id: str): + """ + Clears queue lease metadata after a worker finishes a task. + """ + return supabase.table("tasks").update({ + "leased_at": None, + "lease_expires_at": None, + "queue_worker_id": None, + }).eq("id", task_id).execute() + + @staticmethod + async def mark_failed(task_id: str, error: str): + """ + Stores terminal queue failure metadata. + """ + return supabase.table("tasks").update({ + "status": "failed", + "last_error": error, + "leased_at": None, + "lease_expires_at": None, + "queue_worker_id": None, + "output_data": {"error": error}, + }).eq("id", task_id).execute() + + @staticmethod + async def mark_attempt_failed(task: dict, error: str, max_attempts: int, base_delay_seconds: int): + """ + Requeues a task with exponential backoff until max attempts is reached. + """ + task_id = task["id"] + attempts = int(task.get("queue_attempts") or 0) + + if attempts >= max_attempts: + result = await TaskQueueService.mark_failed(task_id, error) + await audit_service.log_action( + user_id=None, + action="task_queue_terminal_failure", + agent_id=task.get("assigned_agent_id"), + task_id=task_id, + metadata={ + "project_id": task.get("project_id"), + "attempts": attempts, + "max_attempts": max_attempts, + "error": error, + }, + ) + return result + + delay_seconds = max(base_delay_seconds, 1) * (2 ** max(attempts - 1, 0)) + next_attempt_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds) + + result = supabase.table("tasks").update({ + "status": "queued", + "last_error": error, + "leased_at": None, + "lease_expires_at": None, + "next_attempt_at": next_attempt_at.isoformat(), + "queue_worker_id": None, + "output_data": {"error": error, "retrying": True, "next_attempt_at": next_attempt_at.isoformat()}, + }).eq("id", task_id).execute() + await audit_service.log_action( + user_id=None, + action="task_queue_retry_scheduled", + agent_id=task.get("assigned_agent_id"), + task_id=task_id, + metadata={ + "project_id": task.get("project_id"), + "attempts": attempts, + "max_attempts": max_attempts, + "next_attempt_at": next_attempt_at.isoformat(), + "error": error, + }, + ) + return result + + @staticmethod + async def heartbeat( + worker_id: str, + *, + status: str, + current_task_id: str | None = None, + processed_count: int = 0, + failed_count: int = 0, + metadata: dict[str, Any] | None = None, + ): + """ + Upserts worker heartbeat data for operational monitoring. + """ + try: + return supabase.table("worker_heartbeats").upsert({ + "worker_id": worker_id, + "status": status, + "current_task_id": current_task_id, + "processed_count": processed_count, + "failed_count": failed_count, + "metadata": metadata or {}, + "last_seen_at": datetime.now(timezone.utc).isoformat(), + }).execute() + except Exception as e: + logger.warning(f"Could not update worker heartbeat for {worker_id}: {e}") + return None diff --git a/backend/services/task_schemas.py b/backend/services/task_schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..6d9741ec75fe02a75fd7e71eff680bdcdefcc771 --- /dev/null +++ b/backend/services/task_schemas.py @@ -0,0 +1,218 @@ +import json +import re +from typing import Any + + +SCHEMA_DEFINITIONS: dict[str, dict[str, Any]] = { + "factual_research": { + "required": ["summary", "findings"], + "instructions": { + "summary": "string", + "findings": [ + { + "claim": "string", + "source_url": "string or null", + "confidence": "low | medium | high", + } + ], + "unknowns": ["string"], + }, + }, + "comparison": { + "required": ["summary", "entities"], + "instructions": { + "summary": "string", + "entities": [ + { + "name": "string", + "category": "string", + "strengths": ["string"], + "weaknesses": ["string"], + "source_url": "string or null", + } + ], + "differentiators": ["string"], + "gaps": ["string"], + }, + }, + "roadmap": { + "required": ["summary", "recommendations"], + "instructions": { + "summary": "string", + "recommendations": [ + { + "title": "string", + "priority": "low | medium | high", + "rationale": "string", + "timeline": "string", + } + ], + "risks": ["string"], + }, + }, + "workflow_design": { + "required": ["summary", "steps"], + "instructions": { + "summary": "string", + "steps": [ + { + "name": "string", + "owner": "string", + "inputs": ["string"], + "outputs": ["string"], + } + ], + "controls": ["string"], + "success_metrics": ["string"], + }, + }, +} + +SCHEMA_PATTERNS: list[tuple[str, tuple[str, ...]]] = [ + ("comparison", ("competitor", "compare", "comparison", "matrix", "benchmark", "swot")), + ("factual_research", ("research", "market", "pricing", "revenue", "release", "source", "evidence", "audit")), + ("roadmap", ("roadmap", "recommendation", "prioritize", "priority", "timeline", "plan")), + ("workflow_design", ("workflow", "process", "design", "architecture", "implementation", "controls")), +] + + +def classify_task_schema(task: dict) -> str | None: + text = " ".join( + str(task.get(key, "") or "") + for key in ("title", "description") + ).lower() + + project = task.get("project") + if isinstance(project, dict): + text = f"{text} {project.get('name', '')} {project.get('description', '')} {project.get('context', '')}".lower() + + for schema_name, terms in SCHEMA_PATTERNS: + if any(term in text for term in terms): + return schema_name + return None + + +def schema_instructions_for_task(task: dict) -> str: + schema_name = classify_task_schema(task) + if not schema_name: + return "" + + schema = SCHEMA_DEFINITIONS[schema_name]["instructions"] + return ( + "Structured output schema:\n" + f"- schema_type: {schema_name}\n" + "- Return valid JSON only for this task.\n" + "- Use this top-level shape:\n" + f"{json.dumps(schema, indent=2)}\n" + "- Use null for unknown source_url values instead of inventing links." + ) + + +def _strip_code_fence(value: str) -> str: + stripped = value.strip() + if not stripped.startswith("```"): + return stripped + + stripped = re.sub(r"^```(?:json)?", "", stripped, flags=re.IGNORECASE).strip() + stripped = re.sub(r"```$", "", stripped).strip() + return stripped + + +def parse_structured_payload(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if not isinstance(value, str): + return None + + stripped = _strip_code_fence(value) + try: + return json.loads(stripped) + except Exception: + match = re.search(r"```json\s*(.*?)\s*```", value, re.IGNORECASE | re.DOTALL) + if match: + try: + return json.loads(match.group(1).strip()) + except Exception: + return None + return None + + +def _primary_payload(result: dict) -> Any: + data = result.get("data") + if data not in (None, "", [], {}): + return parse_structured_payload(data) if isinstance(data, str) else data + raw = result.get("raw_output") + return parse_structured_payload(raw) + + +def _has_source_url(value: Any) -> bool: + if isinstance(value, dict): + source = value.get("source_url") + if isinstance(source, str) and source.startswith(("http://", "https://")): + return True + return any(_has_source_url(item) for item in value.values()) + if isinstance(value, list): + return any(_has_source_url(item) for item in value) + return False + + +def _missing_source_urls(schema_name: str, payload: dict) -> list[str]: + missing: list[str] = [] + if schema_name == "factual_research": + for index, finding in enumerate(payload.get("findings") or [], start=1): + if not isinstance(finding, dict): + continue + source = finding.get("source_url") + if not (isinstance(source, str) and source.startswith(("http://", "https://"))): + missing.append(f"findings[{index}].source_url") + + if schema_name == "comparison": + for index, entity in enumerate(payload.get("entities") or [], start=1): + if not isinstance(entity, dict): + continue + source = entity.get("source_url") + if not (isinstance(source, str) and source.startswith(("http://", "https://"))): + name = entity.get("name") or index + missing.append(f"entities[{name}].source_url") + + return missing + + +def validate_task_schema(task: dict, result: dict) -> dict: + schema_name = classify_task_schema(task) + if not schema_name: + return { + "schema_type": None, + "required": False, + "approved": True, + "structured": False, + "fail_reasons": [], + "missing_fields": [], + } + + payload = _primary_payload(result) + required = SCHEMA_DEFINITIONS[schema_name]["required"] + fail_reasons: list[str] = [] + missing_fields: list[str] = [] + missing_source_urls: list[str] = [] + + if not isinstance(payload, dict): + fail_reasons.append(f"Task requires structured JSON matching schema '{schema_name}'.") + else: + missing_fields = [field for field in required if field not in payload or payload.get(field) in (None, "", [], {})] + if missing_fields: + fail_reasons.append(f"Structured output is missing required fields: {', '.join(missing_fields)}.") + + missing_source_urls = _missing_source_urls(schema_name, payload) + if missing_source_urls: + fail_reasons.append("Structured factual claims require source_url values.") + + return { + "schema_type": schema_name, + "required": True, + "approved": not fail_reasons, + "structured": isinstance(payload, dict), + "fail_reasons": fail_reasons, + "missing_fields": missing_fields, + "missing_source_urls": missing_source_urls, + } diff --git a/backend/services/utils.py b/backend/services/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..288f35804793dcafaeece5f652786b4ae35770bd --- /dev/null +++ b/backend/services/utils.py @@ -0,0 +1,26 @@ +import asyncio +import logging + +logger = logging.getLogger("uvicorn") + +def log_async_task_result(task: asyncio.Task, label: str) -> None: + """ + Callback for asyncio tasks to log their completion status and exceptions. + """ + if task.cancelled(): + logger.warning("%s was cancelled", label) + return + + try: + exc = task.exception() + if exc: + logger.error( + "%s failed: %s", + label, + exc, + exc_info=(type(exc), exc, exc.__traceback__) + ) + except asyncio.InvalidStateError: + logger.error("%s task is not yet finished", label) + except Exception as exc: + logger.error("Error while checking %s result: %s", label, exc) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..e9c09da478c5b50621f5905ad53d77e590f77904 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest +from unittest.mock import MagicMock, patch +import os +import sys + +# Ensure backend directory is in path +backend_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if backend_path not in sys.path: + sys.path.insert(0, backend_path) + +# Mock environment variables before importing app +os.environ["SUPABASE_URL"] = "https://mock.supabase.co" +os.environ["SUPABASE_SERVICE_ROLE_KEY"] = "mock-key" + +with patch("supabase.create_client") as mock_create: + mock_client = MagicMock() + mock_create.return_value = mock_client + from main import app + +from fastapi.testclient import TestClient + +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + +@pytest.fixture +def mock_supabase(): + with patch("services.supabase_service.supabase") as mock: + yield mock + +@pytest.fixture +def mock_project_service(): + with patch("services.project_service.project_service") as mock: + yield mock diff --git a/backend/tests/test_agent_runner.py b/backend/tests/test_agent_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..e8c16e8f04efc434078aef58103700267f001357 --- /dev/null +++ b/backend/tests/test_agent_runner.py @@ -0,0 +1,47 @@ +import pytest +from unittest.mock import MagicMock, patch + +def test_approve_all_tasks_with_blocked(client): + # Patch references WHERE THEY ARE USED (in the router module) + with patch("routers.agent_runner.project_service") as mock_project_service, \ + patch("routers.agent_runner.supabase") as mock_supabase, \ + patch("routers.agent_runner._assert_task_quality") as mock_quality: + + # 1. Setup mocks + mock_project_service.ensure_project_is_mutable.return_value = {"id": "proj-1"} + + # Mock waiting tasks: one good, one bad + mock_supabase.table().select().eq().eq().execute.return_value.data = [ + {"id": "task-good", "title": "Good Task", "output_data": {"quality_review": {"approved": True}}}, + {"id": "task-bad", "title": "Bad Task", "output_data": {"quality_review": {"approved": False, "fail_reasons": ["Poor quality"]}}} + ] + + # Mock the update for the good task + mock_supabase.table().update().eq().in_().execute.return_value.data = [ + {"id": "task-good", "status": "done"} + ] + + # Mock the final check for all tasks done + mock_supabase.table().select().eq().execute.return_value.data = [ + {"status": "done"}, + {"status": "todo"} + ] + + # Setup quality check behavior + def quality_side_effect(task): + if task["id"] == "task-bad": + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="Quality failed") + return + mock_quality.side_effect = quality_side_effect + + # 2. Call the endpoint + response = client.post("/api/tasks/project/proj-1/approve-all") + + # 3. Assertions + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + assert len(data["blocked"]) == 1 + assert data["blocked"][0]["task_id"] == "task-bad" + assert "Quality failed" in data["blocked"][0]["reason"] diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000000000000000000000000000000000000..c34948be59cc270a332f69722d07e1da576a00c9 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,40 @@ +import pytest +from fastapi import HTTPException +from unittest.mock import patch + +def test_read_root(client): + response = client.get("/") + assert response.status_code == 200 + # Depending on whether frontend/dist exists, it returns index.html or JSON + try: + data = response.json() + assert data["status"] == "online" + except Exception: + # If it's index.html, it won't be JSON + assert response.status_code == 200 + +def test_runtime_config(client): + response = client.get("/runtime-config.js") + assert response.status_code == 200 + assert "window.__AUBM_CONFIG__" in response.text + +def test_project_budget_not_found(client): + # Patch the service reference WHERE IT IS USED (in the router) + with patch("routers.orchestrator.project_service") as mock_service: + mock_service.get_project_or_404.side_effect = HTTPException(status_code=404, detail="Project not found") + + response = client.get("/api/orchestrator/projects/non-existent-id/budget") + assert response.status_code == 404 + assert response.json()["detail"] == "Project not found" + +def test_project_locked_error(client): + # Patch the service reference WHERE IT IS USED (in the router) + with patch("routers.orchestrator.project_service") as mock_service: + mock_service.ensure_project_is_mutable.side_effect = HTTPException( + status_code=409, + detail="Completed projects are locked and cannot be modified." + ) + + response = client.post("/api/orchestrator/projects/locked-id/run") + assert response.status_code == 409 + assert "locked" in response.json()["detail"] diff --git a/backend/tools/browser.py b/backend/tools/browser.py new file mode 100644 index 0000000000000000000000000000000000000000..d31153a8d1c09a846fce2685838efc8ef5fe71b4 --- /dev/null +++ b/backend/tools/browser.py @@ -0,0 +1,111 @@ +import logging +from typing import Any + +import httpx +from playwright.async_api import async_playwright + +from services.config import settings + +logger = logging.getLogger("uvicorn") + + +class BrowserTool: + """ + Tools for live web search and direct URL extraction. + """ + + def __init__(self) -> None: + self.tavily_api_key = settings.TAVILY_API_KEY + + async def search_and_extract(self, url: str) -> str: + """ + Navigates to a URL and returns the page text content. + """ + logger.info("BrowserTool: Navigating to %s", url) + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=True) + page = await browser.new_page() + try: + await page.goto(url, wait_until="networkidle", timeout=30000) + title = await page.title() + content = await page.inner_text("body") + combined = f"Title: {title}\nURL: {url}\n\n{content}".strip() + return combined[:12000] + except Exception as exc: + logger.error("BrowserTool extract error for %s: %s", url, exc) + return f"Error accessing {url}: {exc}" + finally: + await browser.close() + + async def web_search(self, query: str, topic: str = "general", max_results: int = 5) -> str: + """ + Searches the public web with Tavily and returns LLM-friendly results. + """ + if not self.tavily_api_key: + return ( + "Web search is unavailable: TAVILY_API_KEY is not configured. " + "Add it to the backend environment to enable internet search." + ) + + payload = { + "query": query, + "topic": topic if topic in {"general", "news", "finance"} else "general", + "search_depth": "advanced", + "max_results": max(1, min(max_results, 10)), + "include_answer": "advanced", + "include_raw_content": False, + "include_images": False, + } + + headers = { + "Authorization": f"Bearer {self.tavily_api_key}", + "Content-Type": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=45.0) as client: + response = await client.post( + "https://api.tavily.com/search", + headers=headers, + json=payload, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = exc.response.text[:500] if exc.response is not None else str(exc) + logger.error("Tavily HTTP error: %s", detail) + return f"Tavily search failed with status {exc.response.status_code}: {detail}" + except Exception as exc: + logger.error("Tavily request error: %s", exc) + return f"Tavily search failed: {exc}" + + data = response.json() + return self._format_tavily_results(query, data) + + def _format_tavily_results(self, query: str, data: dict[str, Any]) -> str: + answer = data.get("answer") + results = data.get("results") or [] + + lines = [f"Search query: {query}"] + if answer: + lines.extend(["", "Answer:", str(answer).strip()]) + + if not results: + lines.extend(["", "No search results returned."]) + return "\n".join(lines) + + lines.extend(["", "Sources:"]) + for index, result in enumerate(results, start=1): + title = result.get("title") or "Untitled" + url = result.get("url") or "" + snippet = (result.get("content") or "").strip() + score = result.get("score") + + lines.append(f"{index}. {title}") + if url: + lines.append(f" URL: {url}") + if score is not None: + lines.append(f" Score: {score}") + if snippet: + lines.append(f" Snippet: {snippet[:900]}") + + return "\n".join(lines)[:12000] diff --git a/backend/tools/decomposer.py b/backend/tools/decomposer.py new file mode 100644 index 0000000000000000000000000000000000000000..6f568eab896f881815afe79a6f3d685ba24d3107 --- /dev/null +++ b/backend/tools/decomposer.py @@ -0,0 +1,20 @@ +from services.project_service import project_service +from typing import List, Dict, Any +import logging + +logger = logging.getLogger("uvicorn") + +class DecompositionTool: + """ + A tool that allows agents to break down complex goals into actionable tasks. + """ + async def create_subtasks(self, project_id: str, tasks: List[Dict[str, Any]]) -> str: + """ + Takes a list of task definitions and adds them to the database for the given project. + """ + logger.info(f"DecompositionTool: Creating {len(tasks)} subtasks for project {project_id}") + try: + await project_service.add_tasks_to_project(project_id, tasks) + return f"Successfully created {len(tasks)} subtasks." + except Exception as e: + return f"Failed to create subtasks: {str(e)}" diff --git a/backend/tools/file_generator.py b/backend/tools/file_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..ad12b2ed3fe44ba7a601ddc47dda9b4d7a51eca3 --- /dev/null +++ b/backend/tools/file_generator.py @@ -0,0 +1,61 @@ +import os +import pandas as pd +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from datetime import datetime +import logging + +logger = logging.getLogger("uvicorn") + +class FileGeneratorTool: + """ + A tool that allows agents to generate PDF and Excel files. + """ + def __init__(self): + self.output_dir = "outputs" + os.makedirs(self.output_dir, exist_ok=True) + + def _generate_filename(self, extension: str) -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return os.path.join(self.output_dir, f"report_{timestamp}.{extension}") + + async def generate_pdf(self, title: str, content: str) -> str: + """ + Generates a PDF document with the provided title and content. + """ + filename = self._generate_filename("pdf") + logger.info(f"FileGenerator: Generating PDF {filename}") + + try: + c = canvas.Canvas(filename, pagesize=letter) + width, height = letter + + # Title + c.setFont("Helvetica-Bold", 16) + c.drawString(72, height - 72, title) + + # Content (very basic wrapping/split) + c.setFont("Helvetica", 12) + text_object = c.beginText(72, height - 100) + for line in content.split('\n'): + text_object.textLine(line) + c.drawText(text_object) + + c.save() + return f"PDF generated successfully: {filename}" + except Exception as e: + return f"Failed to generate PDF: {str(e)}" + + async def generate_excel(self, data: list) -> str: + """ + Generates an Excel file from a list of dictionaries. + """ + filename = self._generate_filename("xlsx") + logger.info(f"FileGenerator: Generating Excel {filename}") + + try: + df = pd.DataFrame(data) + df.to_excel(filename, index=False) + return f"Excel generated successfully: {filename}" + except Exception as e: + return f"Failed to generate Excel: {str(e)}" diff --git a/backend/tools/registry.py b/backend/tools/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..952d3999bfd2dfaec213b79aa845456690c6819c --- /dev/null +++ b/backend/tools/registry.py @@ -0,0 +1,260 @@ +from .file_generator import FileGeneratorTool +from .decomposer import DecompositionTool +from .sre import SRETool +from .browser import BrowserTool +from .sandbox import CodeSandboxTool +from .visuals import VisualsTool +from typing import Any, Dict, List + +class ToolRegistry: + def __init__(self): + self.browser = BrowserTool() + self.sandbox = CodeSandboxTool() + self.file_gen = FileGeneratorTool() + self.decomposer = DecompositionTool() + self.sre = SRETool() + self.visuals = VisualsTool() + self.tools = { + "web_search": { + "func": self.browser.web_search, + "description": "Searches the public web using Tavily and returns summarized results with source URLs." + }, + "extract_url": { + "func": self.browser.search_and_extract, + "description": "Extracts text content from a specific URL." + }, + "execute_python": { + "func": self.sandbox.execute_python, + "description": "Executes Python code and returns the output." + }, + "generate_pdf": { + "func": self.file_gen.generate_pdf, + "description": "Generates a PDF document." + }, + "generate_excel": { + "func": self.file_gen.generate_excel, + "description": "Generates an Excel spreadsheet from structured data." + }, + "create_subtasks": { + "func": self.decomposer.create_subtasks, + "description": "Breaks down a goal into a list of actionable tasks." + }, + "generate_chart": { + "func": self.visuals.generate_chart, + "description": "Generates a chart image (bar, line, pie) from a JSON config." + }, + "generate_illustration": { + "func": self.visuals.generate_illustration, + "description": "Generates an AI illustration or drawing based on a text prompt." + }, + "get_system_health": { + "func": self.sre.get_system_health, + "description": "Returns system health metrics (CPU, Memory, Disk)." + }, + "check_service_status": { + "func": self.sre.check_service_status, + "description": "Checks if a specific service or process is running." + }, + "run_patch_command": { + "func": self.sre.run_patch_command, + "description": "Executes a safe system patch command (e.g., git pull, npm install)." + } + } + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + """ + Returns OpenAI-style tool definitions. + """ + return [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "Search the public web for information using Tavily. Use this when the task requires current external information.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query"}, + "topic": { + "type": "string", + "enum": ["general", "news", "finance"], + "description": "The search category. Use news for recent events and finance for market/company financial queries." + }, + "max_results": { + "type": "integer", + "description": "Maximum number of results to return. Keep this small to control context size.", + "default": 5 + } + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "extract_url", + "description": "Extract text content from a URL", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL to extract from"} + }, + "required": ["url"] + } + } + }, + { + "type": "function", + "function": { + "name": "execute_python", + "description": "Execute Python code to perform calculations, data analysis, or logic verification.", + "parameters": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "The Python code to execute"} + }, + "required": ["code"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_pdf", + "description": "Create a professional PDF report", + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "The title of the report"}, + "content": {"type": "string", "description": "The text content of the report"} + }, + "required": ["title", "content"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_excel", + "description": "Create an Excel spreadsheet from data", + "parameters": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": {"type": "object"}, + "description": "List of rows as objects" + } + }, + "required": ["data"] + } + } + }, + { + "type": "function", + "function": { + "name": "create_subtasks", + "description": "Break down a complex goal into smaller, actionable tasks for other agents.", + "parameters": { + "type": "object", + "properties": { + "project_id": {"type": "string", "description": "The current project UUID"}, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Clear title of the subtask"}, + "description": {"type": "string", "description": "Detailed instructions for the next agent"}, + "assigned_agent_id": {"type": "string", "description": "The UUID of the agent to handle this task"} + }, + "required": ["title", "description", "assigned_agent_id"] + } + } + }, + "required": ["project_id", "tasks"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_chart", + "description": "Generate a visual chart image (bar, line, pie, etc.) using QuickChart.io.", + "parameters": { + "type": "object", + "properties": { + "chart_type": {"type": "string", "enum": ["bar", "line", "pie", "doughnut"], "description": "Type of chart"}, + "chart_config": {"type": "string", "description": "The JSON configuration for QuickChart (e.g., {type: 'bar', data: {...}})"} + }, + "required": ["chart_type", "chart_config"] + } + } + }, + { + "type": "function", + "function": { + "name": "generate_illustration", + "description": "Generate an AI illustration or drawing based on a prompt using Pollinations.ai.", + "parameters": { + "type": "object", + "properties": { + "prompt": {"type": "string", "description": "Detailed description of the illustration to generate"} + }, + "required": ["prompt"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_system_health", + "description": "Monitor server vital signs like CPU usage, memory availability, and disk space.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "check_service_status", + "description": "Verify if a critical service or process is currently active on the host.", + "parameters": { + "type": "object", + "properties": { + "service_name": {"type": "string", "description": "The name of the process or service to check"} + }, + "required": ["service_name"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_patch_command", + "description": "Apply a system patch or update. Limited to safe commands like 'git pull' or 'npm install'.", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "The restricted command to execute"} + }, + "required": ["command"] + } + } + } + ] + + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any: + """ + Executes a tool by name with provided arguments. + """ + if name not in self.tools: + raise ValueError(f"Tool {name} not found") + + func = self.tools[name]["func"] + return await func(**arguments) + +tool_registry = ToolRegistry() diff --git a/backend/tools/sandbox.py b/backend/tools/sandbox.py new file mode 100644 index 0000000000000000000000000000000000000000..dc8d5ec1290e8c71aa23ad261ddf56248cfb3309 --- /dev/null +++ b/backend/tools/sandbox.py @@ -0,0 +1,39 @@ +import sys +import io +import contextlib +import logging + +logger = logging.getLogger("uvicorn") + +class CodeSandboxTool: + """ + A tool that allows agents to execute Python code and see the output. + """ + async def execute_python(self, code: str) -> str: + """ + Executes the provided Python code and returns the stdout/stderr. + """ + logger.info("CodeSandboxTool: Executing Python code") + + # Capture stdout and stderr + stdout = io.StringIO() + stderr = io.StringIO() + + try: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + # Using a fresh globals dictionary for each execution + exec_globals = {} + exec(code, exec_globals) + + output = stdout.getvalue() + errors = stderr.getvalue() + + if errors: + return f"Output:\n{output}\nErrors:\n{errors}" + return output if output else "Execution successful (no output)." + + except Exception as e: + return f"Execution failed: {str(e)}" + finally: + stdout.close() + stderr.close() diff --git a/backend/tools/sre.py b/backend/tools/sre.py new file mode 100644 index 0000000000000000000000000000000000000000..3a040630b4d844903d8f24cf3abf5fe2b2a8d39f --- /dev/null +++ b/backend/tools/sre.py @@ -0,0 +1,72 @@ +import psutil +import os +import platform +from typing import Dict, Any +import logging + +logger = logging.getLogger("uvicorn") + +class SRETool: + """ + A toolset for Site Reliability Engineering (SRE) agents to monitor and manage system health. + """ + + async def get_system_health(self) -> Dict[str, Any]: + """ + Returns real-time system health metrics (CPU, RAM, Disk). + """ + logger.info("SRETool: Gathering system health metrics") + return { + "cpu_percent": psutil.cpu_percent(interval=1), + "memory": { + "total": psutil.virtual_memory().total, + "available": psutil.virtual_memory().available, + "percent": psutil.virtual_memory().percent + }, + "disk": { + "total": psutil.disk_usage('/').total, + "used": psutil.disk_usage('/').used, + "percent": psutil.disk_usage('/').percent + }, + "os": platform.system(), + "uptime": self._get_uptime() + } + + async def check_service_status(self, service_name: str) -> str: + """ + Checks if a specific service/process is running. + """ + logger.info(f"SRETool: Checking status of {service_name}") + for proc in psutil.process_iter(['name']): + if service_name.lower() in proc.info['name'].lower(): + return f"Service '{service_name}' is RUNNING." + return f"Service '{service_name}' is NOT running." + + def _get_uptime(self) -> str: + # Simple uptime calculation + import time + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + return f"{int(uptime_seconds // 3600)}h {int((uptime_seconds % 3600) // 60)}m" + + async def run_patch_command(self, command: str) -> str: + """ + Executes a restricted set of patching commands. + """ + logger.warning(f"SRETool: Attempting to run patch command: {command}") + + # Restricted whitelist for security + whitelist = ["npm install", "pip install", "git pull", "npm audit fix"] + + is_safe = any(command.startswith(safe) for safe in whitelist) + if not is_safe: + return f"Command '{command}' is not in the safety whitelist. Patch rejected." + + try: + import subprocess + result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + return f"Patch successful: {result.stdout}" + return f"Patch failed: {result.stderr}" + except Exception as e: + return f"Error executing patch: {str(e)}" diff --git a/backend/tools/visuals.py b/backend/tools/visuals.py new file mode 100644 index 0000000000000000000000000000000000000000..9813d40a3f53026b393294a2ceae879ba946096d --- /dev/null +++ b/backend/tools/visuals.py @@ -0,0 +1,48 @@ +import urllib.parse +import json +import logging + +logger = logging.getLogger("uvicorn") + +class VisualsTool: + """ + Provides visual generation capabilities like charts and illustrations. + """ + + async def generate_chart(self, chart_type: str, chart_config: str) -> str: + """ + Generates a chart using QuickChart.io. + chart_type: 'bar', 'line', 'pie', 'doughnut' + chart_config: A JSON string containing the QuickChart configuration. + """ + try: + # If chart_config is already a dict, convert to JSON + if isinstance(chart_config, dict): + config_str = json.dumps(chart_config) + else: + # Try to parse it to validate + config_str = chart_config + json.loads(config_str) + + encoded_config = urllib.parse.quote(config_str) + url = f"https://quickchart.io/chart?c={encoded_config}" + + logger.info(f"Generated chart URL: {url}") + return f"Chart generated successfully: {url}. You should include this URL in your markdown output as an image: ![Chart]({url})" + except Exception as e: + logger.error(f"Failed to generate chart: {e}") + return f"Error generating chart: {str(e)}. Please ensure your chart_config is a valid JSON string." + + async def generate_illustration(self, prompt: str) -> str: + """ + Generates an illustration using Pollinations.ai. + """ + try: + encoded_prompt = urllib.parse.quote(prompt) + url = f"https://pollinations.ai/p/{encoded_prompt}?width=1024&height=1024&seed=42&model=flux" + + logger.info(f"Generated illustration URL: {url}") + return f"Illustration generated successfully: {url}. You should include this URL in your markdown output as an image: ![Illustration]({url})" + except Exception as e: + logger.error(f"Failed to generate illustration: {e}") + return f"Error generating illustration: {str(e)}" diff --git a/backend/worker.py b/backend/worker.py new file mode 100644 index 0000000000000000000000000000000000000000..bfe46a7e0a4691d9b6b45fd48f9be2d046ee0c49 --- /dev/null +++ b/backend/worker.py @@ -0,0 +1,133 @@ +import asyncio +import logging +import os +import signal +import socket +import uuid +from services.task_queue import TaskQueueService +from services.supabase_service import supabase +from services.agent_runner_service import AgentRunnerService +from services.budget_service import BudgetExceededError +from services.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("worker") + +class AubmWorker: + def __init__(self): + self.running = True + suffix = uuid.uuid4().hex[:8] + self.worker_id = os.getenv("AUBM_WORKER_ID") or f"{socket.gethostname()}-{suffix}" + self.lease_seconds = int(os.getenv("AUBM_WORKER_LEASE_SECONDS", "300")) + self.max_attempts = int(os.getenv("AUBM_WORKER_MAX_ATTEMPTS", "3")) + self.retry_delay_seconds = int(os.getenv("AUBM_WORKER_RETRY_DELAY_SECONDS", "30")) + self.idle_poll_seconds = max(int(getattr(settings, "TASK_QUEUE_IDLE_POLL_SECONDS", 10)), 1) + self.processed_count = 0 + self.failed_count = 0 + + async def heartbeat(self, status: str, current_task_id: str | None = None): + if not settings.TASK_QUEUE_HEARTBEAT_ENABLED: + return + + await TaskQueueService.heartbeat( + self.worker_id, + status=status, + current_task_id=current_task_id, + processed_count=self.processed_count, + failed_count=self.failed_count, + metadata={ + "lease_seconds": self.lease_seconds, + "max_attempts": self.max_attempts, + "retry_delay_seconds": self.retry_delay_seconds, + "idle_poll_seconds": self.idle_poll_seconds, + }, + ) + + async def _heartbeat_loop(self): + """Separate loop to send heartbeat at a fixed interval.""" + while self.running: + try: + # We use a longer interval for regular heartbeats + await self.heartbeat("idle") + except Exception as e: + logger.warning("Background heartbeat failed: %s", e) + await asyncio.sleep(30) # Regular heartbeat every 30 seconds + + async def start(self): + mode_suffix = "" if settings.TASK_QUEUE_HEARTBEAT_ENABLED else " (HEARTBEAT DISABLED)" + logger.info(f"Aubm Background Worker started{mode_suffix}: {self.worker_id}") + + # Start the background heartbeat task if enabled + heartbeat_task = None + if settings.TASK_QUEUE_HEARTBEAT_ENABLED: + heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + try: + while self.running: + task = await TaskQueueService.claim_next_queued_task( + self.worker_id, + lease_seconds=self.lease_seconds, + max_attempts=self.max_attempts, + ) + + if task: + task_id = task['id'] + logger.info("Processing task: %s", task_id) + await self.heartbeat("processing", task_id) + + try: + # Fetch agent data for this task + agent_id = task.get("assigned_agent_id") + if not agent_id: + raise RuntimeError("No agent assigned to queued task") + + agent_res = supabase.table("agents").select("*").eq("id", agent_id).single().execute() + if agent_res.data: + await AgentRunnerService.execute_agent_logic(task, agent_res.data) + await TaskQueueService.clear_lease(task_id) + self.processed_count += 1 + await self.heartbeat("idle") + logger.info("Task %s completed successfully.", task_id) + else: + raise RuntimeError(f"Assigned agent not found: {agent_id}") + except BudgetExceededError as e: + logger.warning("Budget blocked queued task %s: %s", task_id, e) + self.failed_count += 1 + await TaskQueueService.mark_failed(task_id, str(e)) + await self.heartbeat("error") + except Exception as e: + logger.error("Failed to process task %s: %s", task_id, e) + self.failed_count += 1 + await TaskQueueService.mark_attempt_failed( + task, + str(e), + self.max_attempts, + self.retry_delay_seconds, + ) + else: + await asyncio.sleep(max(self.idle_poll_seconds, 1)) + finally: + if heartbeat_task: + heartbeat_task.cancel() + await self.heartbeat("stopping") + + def stop(self): + logger.info("Stopping worker...") + self.running = False + +async def main(): + worker = AubmWorker() + + # Handle shutdown signals + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, worker.stop) + except NotImplementedError: + signal.signal(sig, lambda *_: worker.stop()) + + await worker.start() + await worker.heartbeat("stopping") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/database/FINAL_SUPABASE_FIX.sql b/database/FINAL_SUPABASE_FIX.sql new file mode 100644 index 0000000000000000000000000000000000000000..61ac5ff7b2a1ead947f223c884b5c67fc555979e --- /dev/null +++ b/database/FINAL_SUPABASE_FIX.sql @@ -0,0 +1,43 @@ +-- FINAL ROBUST FIX: DATABASE VIEWS WITH AUTH JOIN (Phase 8) +-- RUN THIS IN SUPABASE SQL EDITOR TO RESOLVE ALL GOVERNANCE ISSUES + +-- 1. TEAM MEMBERS VIEW (Joining auth.users for guaranteed email) +CREATE OR REPLACE VIEW public.team_members_with_profiles AS +SELECT + tm.id, + tm.team_id, + tm.user_id, + tm.role, + tm.created_at, + p.full_name, + u.email +FROM public.team_members tm +LEFT JOIN public.profiles p ON tm.user_id = p.id +LEFT JOIN auth.users u ON tm.user_id = u.id; + +GRANT SELECT ON public.team_members_with_profiles TO authenticated; + +-- 2. AUDIT LOGS VIEW +CREATE OR REPLACE VIEW public.audit_logs_with_details AS +SELECT + al.id, + al.user_id, + al.action, + al.agent_id, + al.task_id, + al.metadata, + al.created_at, + p.full_name AS actor_name, + u.email AS actor_email, + ag.name AS agent_name, + t.title AS task_title +FROM public.audit_logs al +LEFT JOIN public.profiles p ON al.user_id = p.id +LEFT JOIN auth.users u ON al.user_id = u.id +LEFT JOIN public.agents ag ON al.agent_id = ag.id +LEFT JOIN public.tasks t ON al.task_id = t.id; + +GRANT SELECT ON public.audit_logs_with_details TO authenticated; + +-- 3. RE-SYNC SCHEMA +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_audit_mutation_triggers.sql b/database/add_audit_mutation_triggers.sql new file mode 100644 index 0000000000000000000000000000000000000000..d666ad9884bc051505f40180e0d71570ab15b168 --- /dev/null +++ b/database/add_audit_mutation_triggers.sql @@ -0,0 +1,88 @@ +-- Add table-level audit triggers for mutations that may happen directly through Supabase. +-- This complements backend audit events and covers frontend writes to projects, tasks, +-- agents, and profiles. + +CREATE OR REPLACE FUNCTION public.log_table_mutation_audit() +RETURNS TRIGGER AS $$ +DECLARE + actor_id UUID; + old_data JSONB; + new_data JSONB; + row_data JSONB; + changed_keys TEXT[]; + audit_task_id UUID; + project_ref TEXT; +BEGIN + actor_id := auth.uid(); + old_data := CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) ELSE NULL END; + new_data := CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) ELSE NULL END; + row_data := COALESCE(new_data, old_data, '{}'::jsonb); + audit_task_id := NULL; + project_ref := NULL; + + IF TG_TABLE_NAME = 'tasks' AND row_data ? 'id' THEN + audit_task_id := (row_data->>'id')::uuid; + project_ref := row_data->>'project_id'; + ELSIF TG_TABLE_NAME = 'projects' AND row_data ? 'id' THEN + project_ref := row_data->>'id'; + END IF; + + IF TG_OP = 'UPDATE' THEN + SELECT COALESCE(array_agg(key ORDER BY key), ARRAY[]::text[]) + INTO changed_keys + FROM ( + SELECT COALESCE(n.key, o.key) AS key + FROM jsonb_each(old_data) AS o + FULL JOIN jsonb_each(new_data) AS n ON n.key = o.key + WHERE o.value IS DISTINCT FROM n.value + ) changed; + ELSE + changed_keys := ARRAY[]::text[]; + END IF; + + INSERT INTO public.audit_logs (user_id, task_id, action, metadata) + VALUES ( + actor_id, + audit_task_id, + lower(TG_TABLE_NAME || '_' || TG_OP), + jsonb_build_object( + 'table', TG_TABLE_NAME, + 'operation', TG_OP, + 'record_id', row_data->>'id', + 'project_id', project_ref, + 'changed_fields', changed_keys + ) + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE ALL ON FUNCTION public.log_table_mutation_audit() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.log_table_mutation_audit() TO authenticated; +GRANT EXECUTE ON FUNCTION public.log_table_mutation_audit() TO service_role; + +DROP TRIGGER IF EXISTS audit_projects_mutations ON public.projects; +CREATE TRIGGER audit_projects_mutations +AFTER INSERT OR UPDATE OR DELETE ON public.projects +FOR EACH ROW EXECUTE FUNCTION public.log_table_mutation_audit(); + +DROP TRIGGER IF EXISTS audit_tasks_mutations ON public.tasks; +CREATE TRIGGER audit_tasks_mutations +AFTER INSERT OR UPDATE OR DELETE ON public.tasks +FOR EACH ROW EXECUTE FUNCTION public.log_table_mutation_audit(); + +DROP TRIGGER IF EXISTS audit_agents_mutations ON public.agents; +CREATE TRIGGER audit_agents_mutations +AFTER INSERT OR UPDATE OR DELETE ON public.agents +FOR EACH ROW EXECUTE FUNCTION public.log_table_mutation_audit(); + +DROP TRIGGER IF EXISTS audit_profiles_mutations ON public.profiles; +CREATE TRIGGER audit_profiles_mutations +AFTER UPDATE ON public.profiles +FOR EACH ROW EXECUTE FUNCTION public.log_table_mutation_audit(); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_entity_aliases.sql b/database/add_entity_aliases.sql new file mode 100644 index 0000000000000000000000000000000000000000..632d4f58d860ff9d53e7c187d5c774bbc61563cf --- /dev/null +++ b/database/add_entity_aliases.sql @@ -0,0 +1,43 @@ +-- Project-scoped entity aliases for normalized evidence. +-- Allows "OpenAI Inc." and "OpenAI" to share the same canonical entity_key. + +CREATE TABLE IF NOT EXISTS public.project_entity_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + canonical_name TEXT NOT NULL, + canonical_key TEXT NOT NULL, + alias TEXT NOT NULL, + alias_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(project_id, alias_key) +); + +CREATE INDEX IF NOT EXISTS project_entity_aliases_project_idx +ON public.project_entity_aliases(project_id); + +CREATE INDEX IF NOT EXISTS project_entity_aliases_canonical_key_idx +ON public.project_entity_aliases(project_id, canonical_key); + +ALTER TABLE public.project_entity_aliases ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role can manage project entity aliases" ON public.project_entity_aliases; +DROP POLICY IF EXISTS "Project entity aliases visible through projects" ON public.project_entity_aliases; + +CREATE POLICY "Service role can manage project entity aliases" ON public.project_entity_aliases + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project entity aliases visible through projects" ON public.project_entity_aliases + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = project_entity_aliases.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_profile_manager_role.sql b/database/add_profile_manager_role.sql new file mode 100644 index 0000000000000000000000000000000000000000..b4e11206fa8ea309edc4902d98f753973e72cabf --- /dev/null +++ b/database/add_profile_manager_role.sql @@ -0,0 +1,11 @@ +-- Allow the manager role in existing Supabase projects. +-- New installs already include this in database/schema.sql. + +ALTER TABLE public.profiles +DROP CONSTRAINT IF EXISTS profiles_role_check; + +ALTER TABLE public.profiles +ADD CONSTRAINT profiles_role_check +CHECK (role IN ('user', 'manager', 'admin')); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_project_budgets.sql b/database/add_project_budgets.sql new file mode 100644 index 0000000000000000000000000000000000000000..7ec17c442aaa5592cb78730dc524f18bac7f6961 --- /dev/null +++ b/database/add_project_budgets.sql @@ -0,0 +1,76 @@ +-- Project-level execution budgets and estimated usage accounting. +-- Budgets are optional: when no budget row exists, execution is not blocked. + +CREATE TABLE IF NOT EXISTS public.project_budgets ( + project_id UUID PRIMARY KEY REFERENCES public.projects(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + token_budget INTEGER, + cost_budget NUMERIC(12, 4), + currency TEXT NOT NULL DEFAULT 'USD', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.project_usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + task_id UUID REFERENCES public.tasks(id) ON DELETE SET NULL, + run_id UUID REFERENCES public.task_runs(id) ON DELETE SET NULL, + agent_id UUID REFERENCES public.agents(id) ON DELETE SET NULL, + provider TEXT, + model TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost NUMERIC(12, 6) NOT NULL DEFAULT 0, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS project_usage_events_project_idx +ON public.project_usage_events(project_id); + +CREATE INDEX IF NOT EXISTS project_usage_events_task_idx +ON public.project_usage_events(task_id); + +ALTER TABLE public.project_budgets ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.project_usage_events ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role can manage project budgets" ON public.project_budgets; +DROP POLICY IF EXISTS "Project budgets visible through projects" ON public.project_budgets; +DROP POLICY IF EXISTS "Service role can manage project usage" ON public.project_usage_events; +DROP POLICY IF EXISTS "Project usage visible through projects" ON public.project_usage_events; + +CREATE POLICY "Service role can manage project budgets" ON public.project_budgets + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project budgets visible through projects" ON public.project_budgets + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = project_budgets.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + +CREATE POLICY "Service role can manage project usage" ON public.project_usage_events + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project usage visible through projects" ON public.project_usage_events + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = project_usage_events.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_task_claims.sql b/database/add_task_claims.sql new file mode 100644 index 0000000000000000000000000000000000000000..d3bfc804c2a671f4fe9e857e0d947ac63f462ea6 --- /dev/null +++ b/database/add_task_claims.sql @@ -0,0 +1,59 @@ +-- Normalized task claims and evidence extracted from structured task outputs. + +CREATE TABLE IF NOT EXISTS public.task_claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES public.projects(id) ON DELETE CASCADE, + task_id UUID NOT NULL REFERENCES public.tasks(id) ON DELETE CASCADE, + claim_text TEXT NOT NULL, + claim_type TEXT CHECK (claim_type IN ('finding', 'entity_strength', 'entity_weakness', 'recommendation', 'risk', 'unknown')) DEFAULT 'finding', + entity_name TEXT, + entity_key TEXT, + claim_hash TEXT, + source_url TEXT, + confidence TEXT CHECK (confidence IN ('low', 'medium', 'high', 'unknown')) DEFAULT 'unknown', + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS task_claims_project_idx +ON public.task_claims(project_id); + +CREATE INDEX IF NOT EXISTS task_claims_task_idx +ON public.task_claims(task_id); + +CREATE INDEX IF NOT EXISTS task_claims_entity_idx +ON public.task_claims(entity_name); + +CREATE INDEX IF NOT EXISTS task_claims_entity_key_idx +ON public.task_claims(entity_key); + +CREATE UNIQUE INDEX IF NOT EXISTS task_claims_project_hash_idx +ON public.task_claims(project_id, claim_hash) +WHERE claim_hash IS NOT NULL; + +ALTER TABLE public.task_claims +ADD COLUMN IF NOT EXISTS entity_key TEXT, +ADD COLUMN IF NOT EXISTS claim_hash TEXT; + +ALTER TABLE public.task_claims ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role can manage task claims" ON public.task_claims; +DROP POLICY IF EXISTS "Task claims visible through projects" ON public.task_claims; + +CREATE POLICY "Service role can manage task claims" ON public.task_claims + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Task claims visible through projects" ON public.task_claims + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_claims.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_task_queue_leasing.sql b/database/add_task_queue_leasing.sql new file mode 100644 index 0000000000000000000000000000000000000000..ba08556e09d3ae21369b2d1feaf66db43930f0e7 --- /dev/null +++ b/database/add_task_queue_leasing.sql @@ -0,0 +1,54 @@ +-- Add queue leasing metadata and an atomic claim function for background workers. + +ALTER TABLE public.tasks +ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS leased_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS queue_worker_id TEXT, +ADD COLUMN IF NOT EXISTS queue_attempts INTEGER NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS last_error TEXT; + +CREATE INDEX IF NOT EXISTS tasks_queue_claim_idx +ON public.tasks (status, priority DESC, queued_at, created_at) +WHERE status = 'queued'; + +CREATE OR REPLACE FUNCTION public.claim_next_queued_task( + worker_id TEXT DEFAULT NULL, + lease_seconds INTEGER DEFAULT 300, + max_attempts INTEGER DEFAULT 3 +) +RETURNS SETOF public.tasks +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN QUERY + WITH candidate AS ( + SELECT id + FROM public.tasks + WHERE status = 'queued' + AND COALESCE(queue_attempts, 0) < max_attempts + AND (lease_expires_at IS NULL OR lease_expires_at < NOW()) + ORDER BY priority DESC, COALESCE(queued_at, created_at), created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE public.tasks AS task + SET + status = 'in_progress', + queue_attempts = COALESCE(task.queue_attempts, 0) + 1, + leased_at = NOW(), + lease_expires_at = NOW() + MAKE_INTERVAL(secs => lease_seconds), + queue_worker_id = worker_id, + updated_at = NOW() + FROM candidate + WHERE task.id = candidate.id + RETURNING task.*; +END; +$$; + +REVOKE ALL ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_task_queue_retry_backoff.sql b/database/add_task_queue_retry_backoff.sql new file mode 100644 index 0000000000000000000000000000000000000000..558d1292f8d9fdcbbfecf7c977d060ba9c5b55d0 --- /dev/null +++ b/database/add_task_queue_retry_backoff.sql @@ -0,0 +1,52 @@ +-- Add delayed retry support for queued background tasks. + +ALTER TABLE public.tasks +ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ; + +DROP INDEX IF EXISTS tasks_queue_claim_idx; + +CREATE INDEX IF NOT EXISTS tasks_queue_claim_idx +ON public.tasks (status, priority DESC, next_attempt_at, queued_at, created_at) +WHERE status = 'queued'; + +CREATE OR REPLACE FUNCTION public.claim_next_queued_task( + worker_id TEXT DEFAULT NULL, + lease_seconds INTEGER DEFAULT 300, + max_attempts INTEGER DEFAULT 3 +) +RETURNS SETOF public.tasks +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN QUERY + WITH candidate AS ( + SELECT id + FROM public.tasks + WHERE status = 'queued' + AND COALESCE(queue_attempts, 0) < max_attempts + AND (lease_expires_at IS NULL OR lease_expires_at < NOW()) + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ORDER BY priority DESC, COALESCE(next_attempt_at, queued_at, created_at), COALESCE(queued_at, created_at), created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE public.tasks AS task + SET + status = 'in_progress', + queue_attempts = COALESCE(task.queue_attempts, 0) + 1, + leased_at = NOW(), + lease_expires_at = NOW() + MAKE_INTERVAL(secs => lease_seconds), + queue_worker_id = worker_id, + updated_at = NOW() + FROM candidate + WHERE task.id = candidate.id + RETURNING task.*; +END; +$$; + +REVOKE ALL ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_task_queued_status.sql b/database/add_task_queued_status.sql new file mode 100644 index 0000000000000000000000000000000000000000..944ea16eaea2f775ffb6cc3a7d324e64f60d3dc3 --- /dev/null +++ b/database/add_task_queued_status.sql @@ -0,0 +1,29 @@ +-- Allow async workers to move tasks into the queued state. +-- The existing worker.py and TaskQueueService use tasks.status = 'queued'. + +DO $$ +DECLARE + constraint_name TEXT; +BEGIN + SELECT con.conname + INTO constraint_name + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE nsp.nspname = 'public' + AND rel.relname = 'tasks' + AND con.contype = 'c' + AND pg_get_constraintdef(con.oid) LIKE '%status%' + LIMIT 1; + + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE public.tasks DROP CONSTRAINT %I', constraint_name); + END IF; +END; +$$; + +ALTER TABLE public.tasks +ADD CONSTRAINT tasks_status_check +CHECK (status IN ('todo', 'queued', 'in_progress', 'awaiting_approval', 'done', 'failed', 'cancelled')); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_task_run_duration.sql b/database/add_task_run_duration.sql new file mode 100644 index 0000000000000000000000000000000000000000..479998d7752ce1bb292cdbbc3682ff944a488a7a --- /dev/null +++ b/database/add_task_run_duration.sql @@ -0,0 +1,7 @@ +-- Add execution duration tracking to existing Supabase projects. +-- Apply this migration if task_runs was created before duration_seconds existed. + +ALTER TABLE public.task_runs +ADD COLUMN IF NOT EXISTS duration_seconds NUMERIC(10, 2); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_team_marketplace.sql b/database/add_team_marketplace.sql new file mode 100644 index 0000000000000000000000000000000000000000..cf8a5128d418479ba03649d51097b28fb097db5b --- /dev/null +++ b/database/add_team_marketplace.sql @@ -0,0 +1,44 @@ +-- Support for team-specific agent templates in the marketplace +ALTER TABLE public.agent_templates +ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES public.teams(id) ON DELETE CASCADE, +ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT true; + +-- Update existing templates to be public +UPDATE public.agent_templates SET is_public = true WHERE is_public IS NULL; + +-- Update RLS Policies +DROP POLICY IF EXISTS "Anyone can view templates" ON public.agent_templates; +CREATE POLICY "Anyone can view templates" ON public.agent_templates + FOR SELECT TO authenticated + USING ( + is_public = true + OR (team_id IS NOT NULL AND public.is_team_member(team_id)) + ); + +DROP POLICY IF EXISTS "Users can create their own templates" ON public.agent_templates; +CREATE POLICY "Users can create templates" ON public.agent_templates + FOR INSERT TO authenticated + WITH CHECK ( + auth.uid() = author_id + AND (team_id IS NULL OR public.can_edit_project(team_id)) -- Borrowing can_edit_project logic or using can_admin_team + ); + +-- Better: use can_admin_team for team templates +DROP POLICY IF EXISTS "Users can create templates" ON public.agent_templates; +CREATE POLICY "Users can create templates" ON public.agent_templates + FOR INSERT TO authenticated + WITH CHECK ( + auth.uid() = author_id + AND ( + (team_id IS NULL AND is_public = true) -- Public templates by anyone (could be restricted later) + OR (team_id IS NOT NULL AND public.is_team_member(team_id)) -- Team members can create team templates + ) + ); + +DROP POLICY IF EXISTS "Owners or team admins can delete templates" ON public.agent_templates; +CREATE POLICY "Owners or team admins can delete templates" ON public.agent_templates + FOR DELETE TO authenticated + USING ( + auth.uid() = author_id + OR (team_id IS NOT NULL AND public.can_admin_team(team_id)) + ); diff --git a/database/add_team_permissions.sql b/database/add_team_permissions.sql new file mode 100644 index 0000000000000000000000000000000000000000..b33704f607325295ce83eef8c4e0c7cfb0124409 --- /dev/null +++ b/database/add_team_permissions.sql @@ -0,0 +1,311 @@ +-- Add workspace/team permissions while preserving existing owner-based access. +-- Roles: +-- - admin: manage team membership and edit team projects/tasks +-- - editor: edit team projects/tasks +-- - viewer: read team projects/tasks + +CREATE TABLE IF NOT EXISTS public.teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.team_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES public.teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('admin', 'editor', 'viewer')) DEFAULT 'viewer', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(team_id, user_id) +); + +ALTER TABLE public.teams +ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(); + +ALTER TABLE public.team_members +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(); + +ALTER TABLE public.projects +ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES public.teams(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS team_members_team_user_idx +ON public.team_members(team_id, user_id); + +CREATE INDEX IF NOT EXISTS projects_team_id_idx +ON public.projects(team_id); + +ALTER TABLE public.teams ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.team_members ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.add_team_creator_as_admin() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.created_by IS NOT NULL THEN + INSERT INTO public.team_members (team_id, user_id, role) + VALUES (NEW.id, NEW.created_by, 'admin') + ON CONFLICT (team_id, user_id) DO UPDATE + SET role = 'admin', + updated_at = NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE ALL ON FUNCTION public.add_team_creator_as_admin() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.add_team_creator_as_admin() TO authenticated; +GRANT EXECUTE ON FUNCTION public.add_team_creator_as_admin() TO service_role; + +DROP TRIGGER IF EXISTS add_team_creator_as_admin_trigger ON public.teams; +CREATE TRIGGER add_team_creator_as_admin_trigger +AFTER INSERT ON public.teams +FOR EACH ROW EXECUTE FUNCTION public.add_team_creator_as_admin(); + +CREATE OR REPLACE FUNCTION public.is_team_member(target_team_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.team_members + WHERE team_id = target_team_id + AND user_id = auth.uid() + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION public.can_admin_team(target_team_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.team_members + WHERE team_id = target_team_id + AND user_id = auth.uid() + AND role = 'admin' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION public.can_view_project(target_project_id UUID) +RETURNS BOOLEAN AS $$ +DECLARE + project_owner UUID; + project_team UUID; + project_public BOOLEAN; +BEGIN + SELECT owner_id, team_id, is_public + INTO project_owner, project_team, project_public + FROM public.projects + WHERE id = target_project_id; + + RETURN auth.uid() = project_owner + OR COALESCE(project_public, false) + OR (project_team IS NOT NULL AND public.is_team_member(project_team)); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION public.can_edit_project(target_project_id UUID) +RETURNS BOOLEAN AS $$ +DECLARE + project_owner UUID; + project_team UUID; +BEGIN + SELECT owner_id, team_id + INTO project_owner, project_team + FROM public.projects + WHERE id = target_project_id; + + RETURN auth.uid() = project_owner + OR EXISTS ( + SELECT 1 + FROM public.team_members + WHERE team_id = project_team + AND user_id = auth.uid() + AND role IN ('admin', 'editor') + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE ALL ON FUNCTION public.is_team_member(UUID) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.can_admin_team(UUID) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.can_view_project(UUID) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.can_edit_project(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.is_team_member(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.can_admin_team(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.can_view_project(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.can_edit_project(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_team_member(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.can_admin_team(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.can_view_project(UUID) TO service_role; +GRANT EXECUTE ON FUNCTION public.can_edit_project(UUID) TO service_role; + +-- Teams +DROP POLICY IF EXISTS "Teams are readable by members" ON public.teams; +DROP POLICY IF EXISTS "Users can create teams" ON public.teams; +DROP POLICY IF EXISTS "Team admins can update teams" ON public.teams; +DROP POLICY IF EXISTS "Team admins can delete teams" ON public.teams; + +CREATE POLICY "Teams are readable by members" ON public.teams + FOR SELECT TO authenticated + USING (public.is_team_member(id)); + +CREATE POLICY "Users can create teams" ON public.teams + FOR INSERT TO authenticated + WITH CHECK (created_by = auth.uid()); + +CREATE POLICY "Team admins can update teams" ON public.teams + FOR UPDATE TO authenticated + USING (public.can_admin_team(id)) + WITH CHECK (public.can_admin_team(id)); + +CREATE POLICY "Team admins can delete teams" ON public.teams + FOR DELETE TO authenticated + USING (public.can_admin_team(id)); + +-- Team members +DROP POLICY IF EXISTS "Team members are readable by team" ON public.team_members; +DROP POLICY IF EXISTS "Users can add themselves as team admin on own team" ON public.team_members; +DROP POLICY IF EXISTS "Team admins can add members" ON public.team_members; +DROP POLICY IF EXISTS "Team admins can update members" ON public.team_members; +DROP POLICY IF EXISTS "Team admins can delete members" ON public.team_members; + +CREATE POLICY "Team members are readable by team" ON public.team_members + FOR SELECT TO authenticated + USING (public.is_team_member(team_id)); + +CREATE POLICY "Users can add themselves as team admin on own team" ON public.team_members + FOR INSERT TO authenticated + WITH CHECK ( + user_id = auth.uid() + AND role = 'admin' + AND EXISTS ( + SELECT 1 + FROM public.teams + WHERE teams.id = team_id + AND teams.created_by = auth.uid() + ) + ); + +CREATE POLICY "Team admins can add members" ON public.team_members + FOR INSERT TO authenticated + WITH CHECK (public.can_admin_team(team_id)); + +CREATE POLICY "Team admins can update members" ON public.team_members + FOR UPDATE TO authenticated + USING (public.can_admin_team(team_id)) + WITH CHECK (public.can_admin_team(team_id)); + +CREATE POLICY "Team admins can delete members" ON public.team_members + FOR DELETE TO authenticated + USING (public.can_admin_team(team_id)); + +-- Projects +DROP POLICY IF EXISTS "Projects visibility" ON public.projects; +DROP POLICY IF EXISTS "Projects ownership" ON public.projects; +DROP POLICY IF EXISTS "Team members can view team projects" ON public.projects; +DROP POLICY IF EXISTS "Team admins and editors can modify projects" ON public.projects; +DROP POLICY IF EXISTS "Projects are visible by owner team or public" ON public.projects; +DROP POLICY IF EXISTS "Projects are insertable by owner or team admin" ON public.projects; +DROP POLICY IF EXISTS "Projects are editable by owner or team editors" ON public.projects; +DROP POLICY IF EXISTS "Projects are deletable by owner or team admins" ON public.projects; + +CREATE POLICY "Projects are visible by owner team or public" ON public.projects + FOR SELECT TO authenticated + USING (auth.uid() = owner_id OR is_public = true OR public.is_team_member(team_id)); + +CREATE POLICY "Projects are insertable by owner or team admin" ON public.projects + FOR INSERT TO authenticated + WITH CHECK ( + auth.uid() = owner_id + AND (team_id IS NULL OR public.can_admin_team(team_id)) + ); + +CREATE POLICY "Projects are editable by owner or team editors" ON public.projects + FOR UPDATE TO authenticated + USING (public.can_edit_project(id)) + WITH CHECK ( + auth.uid() = owner_id + OR public.can_edit_project(id) + ); + +CREATE POLICY "Projects are deletable by owner or team admins" ON public.projects + FOR DELETE TO authenticated + USING ( + auth.uid() = owner_id + OR ( + team_id IS NOT NULL + AND public.can_admin_team(team_id) + ) + ); + +-- Tasks inherit project permissions. +DROP POLICY IF EXISTS "Tasks visibility" ON public.tasks; +DROP POLICY IF EXISTS "Project owners can create tasks" ON public.tasks; +DROP POLICY IF EXISTS "Project owners can update tasks" ON public.tasks; +DROP POLICY IF EXISTS "Project owners can delete tasks" ON public.tasks; +DROP POLICY IF EXISTS "Tasks are visible through project access" ON public.tasks; +DROP POLICY IF EXISTS "Tasks are insertable by project editors" ON public.tasks; +DROP POLICY IF EXISTS "Tasks are editable by project editors" ON public.tasks; +DROP POLICY IF EXISTS "Tasks are deletable by project editors" ON public.tasks; + +CREATE POLICY "Tasks are visible through project access" ON public.tasks + FOR SELECT TO authenticated + USING (public.can_view_project(project_id)); + +CREATE POLICY "Tasks are insertable by project editors" ON public.tasks + FOR INSERT TO authenticated + WITH CHECK (public.can_edit_project(project_id)); + +CREATE POLICY "Tasks are editable by project editors" ON public.tasks + FOR UPDATE TO authenticated + USING (public.can_edit_project(project_id)) + WITH CHECK (public.can_edit_project(project_id)); + +CREATE POLICY "Tasks are deletable by project editors" ON public.tasks + FOR DELETE TO authenticated + USING (public.can_edit_project(project_id)); + +-- Optional evidence table integration. Keep this in the team migration so +-- add_task_claims.sql can still run independently before team support exists. +DO $$ +BEGIN + IF to_regclass('public.task_claims') IS NOT NULL THEN + EXECUTE 'DROP POLICY IF EXISTS "Task claims visible through projects" ON public.task_claims'; + EXECUTE 'DROP POLICY IF EXISTS "Task claims visible through project access" ON public.task_claims'; + EXECUTE 'CREATE POLICY "Task claims visible through project access" ON public.task_claims + FOR SELECT TO authenticated + USING (public.can_view_project(project_id))'; + END IF; + + IF to_regclass('public.project_entity_aliases') IS NOT NULL THEN + EXECUTE 'DROP POLICY IF EXISTS "Project entity aliases visible through projects" ON public.project_entity_aliases'; + EXECUTE 'DROP POLICY IF EXISTS "Project entity aliases visible through project access" ON public.project_entity_aliases'; + EXECUTE 'CREATE POLICY "Project entity aliases visible through project access" ON public.project_entity_aliases + FOR SELECT TO authenticated + USING (public.can_view_project(project_id))'; + END IF; + + IF to_regclass('public.project_budgets') IS NOT NULL THEN + EXECUTE 'DROP POLICY IF EXISTS "Project budgets visible through projects" ON public.project_budgets'; + EXECUTE 'DROP POLICY IF EXISTS "Project budgets visible through project access" ON public.project_budgets'; + EXECUTE 'CREATE POLICY "Project budgets visible through project access" ON public.project_budgets + FOR SELECT TO authenticated + USING (public.can_view_project(project_id))'; + END IF; + + IF to_regclass('public.project_usage_events') IS NOT NULL THEN + EXECUTE 'DROP POLICY IF EXISTS "Project usage visible through projects" ON public.project_usage_events'; + EXECUTE 'DROP POLICY IF EXISTS "Project usage visible through project access" ON public.project_usage_events'; + EXECUTE 'CREATE POLICY "Project usage visible through project access" ON public.project_usage_events + FOR SELECT TO authenticated + USING (public.can_view_project(project_id))'; + END IF; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_vector_memory.sql b/database/add_vector_memory.sql new file mode 100644 index 0000000000000000000000000000000000000000..7640c66a9000f4efc80ffa26625fac2617eaba9e --- /dev/null +++ b/database/add_vector_memory.sql @@ -0,0 +1,86 @@ +-- Enable pgvector extension +CREATE EXTENSION IF NOT EXISTS vector; + +-- 1. Upgrade task_claims with vector support +ALTER TABLE public.task_claims +ADD COLUMN IF NOT EXISTS embedding vector(1536); + +-- Index for semantic search on claims +CREATE INDEX IF NOT EXISTS task_claims_embedding_idx +ON public.task_claims +USING ivfflat (embedding vector_cosine_ops) +WITH (lists = 100); + +-- 2. Create Long-Term Project Memory table +-- This stores higher-level insights, approved summaries, and cross-project knowledge. +CREATE TABLE IF NOT EXISTS public.project_memory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES public.projects(id) ON DELETE CASCADE, + task_id UUID REFERENCES public.tasks(id) ON DELETE SET NULL, + content TEXT NOT NULL, + embedding vector(1536), + metadata JSONB DEFAULT '{}'::jsonb, + memory_type TEXT CHECK (memory_type IN ('strategic_insight', 'approved_output', 'code_snippet', 'market_data', 'custom')) DEFAULT 'approved_output', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Index for semantic search on long-term memory +CREATE INDEX IF NOT EXISTS project_memory_embedding_idx +ON public.project_memory +USING ivfflat (embedding vector_cosine_ops) +WITH (lists = 100); + +ALTER TABLE public.project_memory ENABLE ROW LEVEL SECURITY; + +-- Permissions +DROP POLICY IF EXISTS "Service role can manage memory" ON public.project_memory; +CREATE POLICY "Service role can manage memory" ON public.project_memory + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +DROP POLICY IF EXISTS "Memory visible to project owners" ON public.project_memory; +CREATE POLICY "Memory visible to project owners" ON public.project_memory + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = project_memory.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + +-- Helper function for semantic similarity search +CREATE OR REPLACE FUNCTION match_project_memory ( + query_embedding vector(1536), + match_threshold float, + match_count int, + filter_project_id uuid DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + project_id uuid, + content text, + metadata jsonb, + similarity float +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY + SELECT + pm.id, + pm.project_id, + pm.content, + pm.metadata, + 1 - (pm.embedding <=> query_embedding) AS similarity + FROM public.project_memory pm + WHERE (filter_project_id IS NULL OR pm.project_id = filter_project_id) + AND 1 - (pm.embedding <=> query_embedding) > match_threshold + ORDER BY pm.embedding <=> query_embedding + LIMIT match_count; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/database/add_worker_heartbeats.sql b/database/add_worker_heartbeats.sql new file mode 100644 index 0000000000000000000000000000000000000000..3de6125dc6196136160d81b7375cd02cc2978a32 --- /dev/null +++ b/database/add_worker_heartbeats.sql @@ -0,0 +1,23 @@ +-- Track background worker heartbeats for operations monitoring. + +CREATE TABLE IF NOT EXISTS public.worker_heartbeats ( + worker_id TEXT PRIMARY KEY, + status TEXT CHECK (status IN ('starting', 'idle', 'processing', 'stopping', 'error')) DEFAULT 'starting', + current_task_id UUID REFERENCES public.tasks ON DELETE SET NULL, + processed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + metadata JSONB, + started_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE public.worker_heartbeats ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Service role can manage worker heartbeats" ON public.worker_heartbeats; + +CREATE POLICY "Service role can manage worker heartbeats" ON public.worker_heartbeats + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/agent_ownership.sql b/database/agent_ownership.sql new file mode 100644 index 0000000000000000000000000000000000000000..ee813b241b42d6ed1722afa996c44a491e1ad5a4 --- /dev/null +++ b/database/agent_ownership.sql @@ -0,0 +1,42 @@ +-- Agent ownership and marketplace deploy policies +-- Apply this migration to existing Supabase projects after schema.sql. + +ALTER TABLE public.agents +ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users ON DELETE CASCADE; + +ALTER TABLE public.agents ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'agents' + AND policyname = 'Users can create own agents' + ) THEN + CREATE POLICY "Users can create own agents" ON public.agents + FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'agents' + AND policyname = 'Users can update own agents' + ) THEN + CREATE POLICY "Users can update own agents" ON public.agents + FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'agents' + AND policyname = 'Users can delete own agents' + ) THEN + CREATE POLICY "Users can delete own agents" ON public.agents + FOR DELETE TO authenticated USING (auth.uid() = user_id); + END IF; +END $$; + +NOTIFY pgrst, 'reload schema'; diff --git a/database/default_agents.sql b/database/default_agents.sql new file mode 100644 index 0000000000000000000000000000000000000000..104a78e8348ff2b2668f4d9c40875ffe13b5e90c --- /dev/null +++ b/database/default_agents.sql @@ -0,0 +1,36 @@ +-- Global default agents +-- These agents are readable by authenticated users and usable by the backend +-- orchestrator as fallback agents. They are not owned by a specific user. + +INSERT INTO public.agents (name, role, api_provider, model, system_prompt) +SELECT + 'Planner', + 'Project Planner', + 'openai', + 'gpt-4o', + 'You decompose goals into clear, ordered implementation tasks.' +WHERE NOT EXISTS ( + SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Planner' +); + +INSERT INTO public.agents (name, role, api_provider, model, system_prompt) +SELECT + 'Builder', + 'Implementation Agent', + 'openai', + 'gpt-4o', + 'You implement practical, production-oriented solutions with concise output.' +WHERE NOT EXISTS ( + SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Builder' +); + +INSERT INTO public.agents (name, role, api_provider, model, system_prompt) +SELECT + 'Reviewer', + 'Quality Reviewer', + 'openai', + 'gpt-4o', + 'You review outputs for correctness, security, completeness, and missing tests.' +WHERE NOT EXISTS ( + SELECT 1 FROM public.agents WHERE user_id IS NULL AND name = 'Reviewer' +); diff --git a/database/enterprise_security.sql b/database/enterprise_security.sql new file mode 100644 index 0000000000000000000000000000000000000000..32ffc8bc72bdc2f63419cc22530d6adc629b2dee --- /dev/null +++ b/database/enterprise_security.sql @@ -0,0 +1,53 @@ +-- Enterprise Teams Table +CREATE TABLE IF NOT EXISTS teams ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Team Members Table +CREATE TABLE IF NOT EXISTS team_members ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + role TEXT CHECK (role IN ('admin', 'editor', 'viewer')) DEFAULT 'viewer', + UNIQUE(team_id, user_id) +); + +-- Add team_id to Projects +ALTER TABLE projects ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id); + +-- Advanced RLS for Projects +ALTER TABLE projects ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can view projects of their teams +CREATE POLICY "Team members can view team projects" ON projects + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM team_members + WHERE team_members.team_id = projects.team_id + AND team_members.user_id = auth.uid() + ) + ); + +-- Policy: Only admins and editors can modify projects +CREATE POLICY "Team admins and editors can modify projects" ON projects + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM team_members + WHERE team_members.team_id = projects.team_id + AND team_members.user_id = auth.uid() + AND team_members.role IN ('admin', 'editor') + ) + ); + +-- Audit Logs for RLS +CREATE POLICY "Team members can view audit logs" ON audit_logs + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM projects + JOIN team_members ON team_members.team_id = projects.team_id + WHERE projects.id = audit_logs.task_id -- Simplified link + AND team_members.user_id = auth.uid() + ) + ); diff --git a/database/fix_all_user_relationships.sql b/database/fix_all_user_relationships.sql new file mode 100644 index 0000000000000000000000000000000000000000..669a1cc64f5dede6d8adfa833f31d5dbf980f411 --- /dev/null +++ b/database/fix_all_user_relationships.sql @@ -0,0 +1,25 @@ +-- Global Fix for Schema Cache relationship discovery (Phase 8 Governance) +-- Ensures all user-related joins work correctly in PostgREST by pointing to public.profiles. + +-- 1. Projects +ALTER TABLE public.projects DROP CONSTRAINT IF EXISTS projects_owner_id_fkey; +ALTER TABLE public.projects ADD CONSTRAINT projects_owner_id_fkey +FOREIGN KEY (owner_id) REFERENCES public.profiles(id) ON DELETE SET NULL; + +-- 2. Agents +ALTER TABLE public.agents DROP CONSTRAINT IF EXISTS agents_user_id_fkey; +ALTER TABLE public.agents ADD CONSTRAINT agents_user_id_fkey +FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; + +-- 3. Audit Logs +ALTER TABLE public.audit_logs DROP CONSTRAINT IF EXISTS audit_logs_actor_id_fkey; +ALTER TABLE public.audit_logs ADD CONSTRAINT audit_logs_actor_id_fkey +FOREIGN KEY (actor_id) REFERENCES public.profiles(id) ON DELETE SET NULL; + +-- 4. Agent Templates +ALTER TABLE public.agent_templates DROP CONSTRAINT IF EXISTS agent_templates_author_id_fkey; +ALTER TABLE public.agent_templates ADD CONSTRAINT agent_templates_author_id_fkey +FOREIGN KEY (author_id) REFERENCES public.profiles(id) ON DELETE SET NULL; + +-- 5. Notify PostgREST +NOTIFY pgrst, 'reload schema'; diff --git a/database/fix_decommissioned_models.sql b/database/fix_decommissioned_models.sql new file mode 100644 index 0000000000000000000000000000000000000000..604ced94b845af2dfccfb6750f0985863c73deb3 --- /dev/null +++ b/database/fix_decommissioned_models.sql @@ -0,0 +1,19 @@ +-- Migration to fix decommissioned Groq models +-- Replaces llama3-70b-8192 with llama-3.3-70b-versatile across all tables + +-- 1. Update agents (this is where the model is stored) +UPDATE public.agents +SET model = 'llama-3.3-70b-versatile' +WHERE model = 'llama3-70b-8192'; + +-- 2. Update any app_config entries +UPDATE public.app_config +SET value = jsonb_set(value, '{default_model}', '"llama-3.3-70b-versatile"') +WHERE key = 'groq' AND value->>'default_model' = 'llama3-70b-8192'; + +-- 3. Reset failed tasks that were stuck due to this error +-- We don't filter by model here because tasks don't have a model column, +-- but we can filter by the error message logged in 'last_error' +UPDATE public.tasks +SET status = 'queued' +WHERE status = 'failed' AND last_error LIKE '%llama3-70b-8192%'; diff --git a/database/fix_profiles_recursion.sql b/database/fix_profiles_recursion.sql new file mode 100644 index 0000000000000000000000000000000000000000..8fe3d730af45e070fe351a8882d210a595637ba5 --- /dev/null +++ b/database/fix_profiles_recursion.sql @@ -0,0 +1,34 @@ +-- Fix recursive policies in profiles table +-- This migration replaces the existing admin policies with a non-recursive approach + +-- 1. Drop existing problematic policies +DROP POLICY IF EXISTS "Admins can read all profiles" ON public.profiles; +DROP POLICY IF EXISTS "Admins can update all profiles" ON public.profiles; + +-- 2. Create a helper function to check admin status without triggering RLS recursion +-- SECURITY DEFINER runs with the privileges of the creator (usually postgres/service_role) +-- effectively bypassing RLS on the profiles table for this check. +CREATE OR REPLACE FUNCTION public.is_admin_check() +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.profiles + WHERE id = auth.uid() AND role = 'admin' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- 3. Re-create policies using the helper function +CREATE POLICY "Admins can read all profiles" ON public.profiles + FOR SELECT + USING ( public.is_admin_check() ); + +CREATE POLICY "Admins can update all profiles" ON public.profiles + FOR UPDATE TO authenticated + USING ( public.is_admin_check() ) + WITH CHECK ( public.is_admin_check() ); + +-- 4. Restrict and grant access to the function +REVOKE ALL ON FUNCTION public.is_admin_check() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.is_admin_check() TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_admin_check() TO service_role; diff --git a/database/fix_profiles_rls_final.sql b/database/fix_profiles_rls_final.sql new file mode 100644 index 0000000000000000000000000000000000000000..82902f027ae45bb261898854f73b999e00058a27 --- /dev/null +++ b/database/fix_profiles_rls_final.sql @@ -0,0 +1,106 @@ +-- Final profiles RLS hardening. +-- Fixes recursive admin policies and prevents users from escalating their role. + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +-- Admin check used by RLS policies. SECURITY DEFINER avoids recursive RLS checks +-- when reading public.profiles from inside a profile policy. +CREATE OR REPLACE FUNCTION public.is_admin() +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.profiles + WHERE id = auth.uid() + AND role = 'admin' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE ALL ON FUNCTION public.is_admin() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.is_admin() TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_admin() TO service_role; + +-- Role changes need OLD/NEW comparison, which belongs in a trigger rather than +-- a self-referential RLS policy. +CREATE OR REPLACE FUNCTION public.protect_profile_role() +RETURNS TRIGGER AS $$ +DECLARE + jwt_role TEXT; +BEGIN + jwt_role := COALESCE(current_setting('request.jwt.claim.role', true), ''); + + IF TG_OP = 'INSERT' THEN + NEW.role := COALESCE(NEW.role, 'user'); + + IF NEW.role <> 'user' + AND NOT public.is_admin() + AND jwt_role <> 'service_role' + AND current_user NOT IN ('postgres', 'supabase_admin') THEN + RAISE EXCEPTION 'Only admins can create elevated profiles'; + END IF; + + RETURN NEW; + END IF; + + IF NEW.role IS DISTINCT FROM OLD.role + AND NOT public.is_admin() + AND jwt_role <> 'service_role' + AND current_user NOT IN ('postgres', 'supabase_admin') THEN + RAISE EXCEPTION 'Only admins can change profile roles'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +REVOKE ALL ON FUNCTION public.protect_profile_role() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.protect_profile_role() TO authenticated; +GRANT EXECUTE ON FUNCTION public.protect_profile_role() TO service_role; + +DROP TRIGGER IF EXISTS protect_profile_role_trigger ON public.profiles; +CREATE TRIGGER protect_profile_role_trigger +BEFORE INSERT OR UPDATE ON public.profiles +FOR EACH ROW EXECUTE FUNCTION public.protect_profile_role(); + +-- Start from a known policy set. Drop both old and newer names. +DROP POLICY IF EXISTS "Users can read own profile" ON public.profiles; +DROP POLICY IF EXISTS "Users can insert own profile" ON public.profiles; +DROP POLICY IF EXISTS "Users can update own profile" ON public.profiles; +DROP POLICY IF EXISTS "Admins can read all profiles" ON public.profiles; +DROP POLICY IF EXISTS "Admins can update all profiles" ON public.profiles; +DROP POLICY IF EXISTS "Profiles are readable by owners" ON public.profiles; +DROP POLICY IF EXISTS "Profiles are readable by admins" ON public.profiles; +DROP POLICY IF EXISTS "Profiles are insertable by owners" ON public.profiles; +DROP POLICY IF EXISTS "Profiles are updatable by owners" ON public.profiles; +DROP POLICY IF EXISTS "Profiles are updatable by admins" ON public.profiles; + +CREATE POLICY "Profiles are readable by owners" ON public.profiles + FOR SELECT TO authenticated + USING (auth.uid() = id); + +CREATE POLICY "Profiles are readable by admins" ON public.profiles + FOR SELECT TO authenticated + USING (public.is_admin()); + +CREATE POLICY "Profiles are insertable by owners" ON public.profiles + FOR INSERT TO authenticated + WITH CHECK ( + auth.uid() = id + AND COALESCE(role, 'user') = 'user' + ); + +CREATE POLICY "Profiles are updatable by owners" ON public.profiles + FOR UPDATE TO authenticated + USING (auth.uid() = id) + WITH CHECK (auth.uid() = id); + +CREATE POLICY "Profiles are updatable by admins" ON public.profiles + FOR UPDATE TO authenticated + USING (public.is_admin()) + WITH CHECK ( + public.is_admin() + AND role IN ('user', 'manager', 'admin') + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/database/fix_team_members_relationship.sql b/database/fix_team_members_relationship.sql new file mode 100644 index 0000000000000000000000000000000000000000..1407e804314b8e00880d41c7bacbf781454b218f --- /dev/null +++ b/database/fix_team_members_relationship.sql @@ -0,0 +1,18 @@ +-- Fix for Schema Cache relationship error (Phase 8 Governance) +-- This migration ensures PostgREST can discover the relationship between team_members and profiles. + +-- 1. Update foreign key to point to public.profiles instead of auth.users +-- They both share the same UUID, but pointing to public schema helps PostgREST discovery. +ALTER TABLE public.team_members +DROP CONSTRAINT IF EXISTS team_members_user_id_fkey; + +ALTER TABLE public.team_members +ADD CONSTRAINT team_members_user_id_fkey +FOREIGN KEY (user_id) REFERENCES public.profiles(id) ON DELETE CASCADE; + +-- 2. Ensure RLS doesn't block the join +-- (Already handled in fix_teams_rls_governance, but double checking) +GRANT SELECT ON public.profiles TO authenticated; + +-- 3. Notify PostgREST to reload schema +NOTIFY pgrst, 'reload schema'; diff --git a/database/fix_teams_rls_governance.sql b/database/fix_teams_rls_governance.sql new file mode 100644 index 0000000000000000000000000000000000000000..6748015fbeab4001c83b3439c2a9c8a90750356f --- /dev/null +++ b/database/fix_teams_rls_governance.sql @@ -0,0 +1,45 @@ +-- Fix for 403 Forbidden on Teams access (Phase 8 Governance) +-- This migration resolves potential RLS recursion and ensures proper grants. + +-- 1. Ensure table permissions are granted to authenticated users +GRANT SELECT, INSERT, UPDATE, DELETE ON public.teams TO authenticated; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.team_members TO authenticated; +GRANT USAGE ON SCHEMA public TO authenticated; + +-- 2. Drop existing problematic policies +DROP POLICY IF EXISTS "Teams are readable by members" ON public.teams; +DROP POLICY IF EXISTS "Team members are readable by team" ON public.team_members; + +-- 3. Re-implement Teams Select Policy using a non-recursive direct check +-- Users can see teams they belong to or teams they created. +CREATE POLICY "Teams are readable by members" ON public.teams + FOR SELECT TO authenticated + USING ( + created_by = auth.uid() + OR public.is_team_member(id) + ); + +-- 4. Re-implement Team Members Select Policy +-- Users can see membership details of teams they are part of. +CREATE POLICY "Team members are readable by team" ON public.team_members + FOR SELECT TO authenticated + USING ( + user_id = auth.uid() + OR public.is_team_member(team_id) + ); + +-- 5. Ensure the is_team_member function is robust and uses search_path +CREATE OR REPLACE FUNCTION public.is_team_member(target_team_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 + FROM public.team_members + WHERE team_id = target_team_id + AND user_id = auth.uid() + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- 6. Reload schema for PostgREST +NOTIFY pgrst, 'reload schema'; diff --git a/database/force_reset_tasks.sql b/database/force_reset_tasks.sql new file mode 100644 index 0000000000000000000000000000000000000000..c8d31bd894bc4adc2e044e31667162d545278dc0 --- /dev/null +++ b/database/force_reset_tasks.sql @@ -0,0 +1,12 @@ +-- Emergency Reset for stuck tasks +-- Sets attempts to 0 and clears any old leases/errors + +UPDATE tasks +SET + status = 'queued', + queue_attempts = 0, + leased_at = NULL, + lease_expires_at = NULL, + next_attempt_at = NOW(), + queue_worker_id = NULL +WHERE status IN ('queued', 'failed', 'in_progress'); diff --git a/database/marketplace.sql b/database/marketplace.sql new file mode 100644 index 0000000000000000000000000000000000000000..d59d0e17b7b31cca2b09fc95cca993e7fa8345fd --- /dev/null +++ b/database/marketplace.sql @@ -0,0 +1,38 @@ +-- Agent Marketplace Table +CREATE TABLE IF NOT EXISTS public.agent_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + role TEXT NOT NULL, + description TEXT, + model TEXT NOT NULL, + api_provider TEXT NOT NULL, + system_prompt TEXT, + category TEXT, -- e.g., 'Marketing', 'Development', 'Legal' + author_id UUID REFERENCES auth.users(id), + is_featured BOOLEAN DEFAULT false, + usage_count INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS agent_templates_name_role_provider_key +ON public.agent_templates (name, role, api_provider); + +-- RLS for Marketplace (Public View) +ALTER TABLE public.agent_templates ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Anyone can view templates" ON public.agent_templates; +DROP POLICY IF EXISTS "Users can create their own templates" ON public.agent_templates; + +CREATE POLICY "Anyone can view templates" ON public.agent_templates + FOR SELECT USING (true); + +CREATE POLICY "Users can create their own templates" ON public.agent_templates + FOR INSERT WITH CHECK (auth.uid() = author_id); + +-- Seed some marketplace templates +INSERT INTO public.agent_templates (name, role, description, model, api_provider, category, system_prompt) +VALUES +('Growth Hacker', 'Marketing Expert', 'Optimizes funnels and generates viral content ideas.', 'gpt-4o', 'openai', 'Marketing', 'You are a Growth Hacker focused on low-cost, high-impact strategies.'), +('Code Architect', 'Senior Developer', 'Designs robust software architectures and reviews code.', 'gpt-4o', 'openai', 'Development', 'You are a Code Architect. Focus on scalability, security, and clean code.'), +('Legal Analyst', 'Legal Advisor', 'Analyzes contracts and identifies legal risks.', 'gpt-4o', 'openai', 'Legal', 'You are a Legal Analyst. Review documents with high precision and caution.') +ON CONFLICT DO NOTHING; diff --git a/database/phase3_updates.sql b/database/phase3_updates.sql new file mode 100644 index 0000000000000000000000000000000000000000..40d2d59a2d964cb33972939c0e882a2d2a8bd52b --- /dev/null +++ b/database/phase3_updates.sql @@ -0,0 +1,30 @@ +-- Audit Logs Table +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users(id), + agent_id UUID REFERENCES agents(id), + task_id UUID REFERENCES tasks(id), + action TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Feedback Table for Fine-tuning +CREATE TABLE IF NOT EXISTS task_feedback ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + task_id UUID REFERENCES tasks(id) UNIQUE, + user_id UUID REFERENCES auth.users(id), + rating INT CHECK (rating IN (-1, 1)), -- -1 for dislike, 1 for like + comment TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Add RLS to new tables +ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE task_feedback ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own audit logs" ON audit_logs + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can manage their own feedback" ON task_feedback + FOR ALL USING (auth.uid() = user_id); diff --git a/database/profiles_admin.sql b/database/profiles_admin.sql new file mode 100644 index 0000000000000000000000000000000000000000..b93b6e64c217d33b54f90cf8c92c4e2ee56c8965 --- /dev/null +++ b/database/profiles_admin.sql @@ -0,0 +1,128 @@ +-- Apply this migration after database/schema.sql + +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'profiles' + AND policyname = 'Users can read own profile' + ) THEN + CREATE POLICY "Users can read own profile" ON public.profiles + FOR SELECT + USING (auth.uid() = id); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'profiles' + AND policyname = 'Users can insert own profile' + ) THEN + CREATE POLICY "Users can insert own profile" ON public.profiles + FOR INSERT TO authenticated + WITH CHECK ( + auth.uid() = id + AND COALESCE(role, 'user') = 'user' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'profiles' + AND policyname = 'Users can update own profile' + ) THEN + CREATE POLICY "Users can update own profile" ON public.profiles + FOR UPDATE TO authenticated + USING (auth.uid() = id) + WITH CHECK ( + auth.uid() = id + AND role = ( + SELECT p.role + FROM public.profiles p + WHERE p.id = auth.uid() + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'profiles' + AND policyname = 'Admins can read all profiles' + ) THEN + CREATE POLICY "Admins can read all profiles" ON public.profiles + FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM public.profiles admin_profile + WHERE admin_profile.id = auth.uid() + AND admin_profile.role = 'admin' + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'profiles' + AND policyname = 'Admins can update all profiles' + ) THEN + CREATE POLICY "Admins can update all profiles" ON public.profiles + FOR UPDATE TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.profiles admin_profile + WHERE admin_profile.id = auth.uid() + AND admin_profile.role = 'admin' + ) + ) + WITH CHECK ( + role IN ('user', 'manager', 'admin') + AND EXISTS ( + SELECT 1 + FROM public.profiles admin_profile + WHERE admin_profile.id = auth.uid() + AND admin_profile.role = 'admin' + ) + ); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION public.handle_new_user_profile() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.profiles (id, role, full_name, avatar_url) + VALUES ( + NEW.id, + 'user', + COALESCE(NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name'), + NEW.raw_user_meta_data ->> 'avatar_url' + ) + ON CONFLICT (id) DO NOTHING; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS on_auth_user_created_profile ON auth.users; + +CREATE TRIGGER on_auth_user_created_profile +AFTER INSERT ON auth.users +FOR EACH ROW +EXECUTE FUNCTION public.handle_new_user_profile(); + +-- Promote your first administrator manually once: +-- UPDATE public.profiles SET role = 'admin' WHERE id = 'YOUR_USER_UUID'; diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..837d32f2381fc32ac38ec6086a06a55c1c20e00c --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,350 @@ +-- Aubm Database Schema +-- Designed for Supabase (PostgreSQL) + +-- 1. Profiles (User Extensions) +CREATE TABLE IF NOT EXISTS public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE, + role TEXT CHECK (role IN ('user', 'manager', 'admin')) DEFAULT 'user', + full_name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 2. Projects +CREATE TABLE IF NOT EXISTS public.projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + context TEXT, + owner_id UUID REFERENCES auth.users ON DELETE CASCADE, + status TEXT CHECK (status IN ('active', 'archived', 'completed')) DEFAULT 'active', + is_public BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 3. Agents (AI Identities) +CREATE TABLE IF NOT EXISTS public.agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES auth.users ON DELETE CASCADE, + name TEXT NOT NULL, + role TEXT, + api_provider TEXT NOT NULL, + model TEXT NOT NULL, + system_prompt TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 4. Tasks (Units of work) +CREATE TABLE IF NOT EXISTS public.tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES public.projects ON DELETE CASCADE, + assigned_agent_id UUID REFERENCES public.agents ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT CHECK (status IN ('todo', 'queued', 'in_progress', 'awaiting_approval', 'done', 'failed', 'cancelled')) DEFAULT 'todo', + priority INTEGER DEFAULT 0, + is_critical BOOLEAN DEFAULT FALSE, + output_data JSONB, + queued_at TIMESTAMPTZ, + leased_at TIMESTAMPTZ, + lease_expires_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ, + queue_worker_id TEXT, + queue_attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS tasks_queue_claim_idx +ON public.tasks (status, priority DESC, next_attempt_at, queued_at, created_at) +WHERE status = 'queued'; + +CREATE OR REPLACE FUNCTION public.claim_next_queued_task( + worker_id TEXT DEFAULT NULL, + lease_seconds INTEGER DEFAULT 300, + max_attempts INTEGER DEFAULT 3 +) +RETURNS SETOF public.tasks +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + RETURN QUERY + WITH candidate AS ( + SELECT id + FROM public.tasks + WHERE status = 'queued' + AND COALESCE(queue_attempts, 0) < max_attempts + AND (lease_expires_at IS NULL OR lease_expires_at < NOW()) + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ORDER BY priority DESC, COALESCE(next_attempt_at, queued_at, created_at), COALESCE(queued_at, created_at), created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE public.tasks AS task + SET + status = 'in_progress', + queue_attempts = COALESCE(task.queue_attempts, 0) + 1, + leased_at = NOW(), + lease_expires_at = NOW() + MAKE_INTERVAL(secs => lease_seconds), + queue_worker_id = worker_id, + updated_at = NOW() + FROM candidate + WHERE task.id = candidate.id + RETURNING task.*; +END; +$$; + +REVOKE ALL ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.claim_next_queued_task(TEXT, INTEGER, INTEGER) TO service_role; + +-- 5. Task Runs (Execution History) +CREATE TABLE IF NOT EXISTS public.task_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID REFERENCES public.tasks ON DELETE CASCADE, + agent_id UUID REFERENCES public.agents ON DELETE SET NULL, + status TEXT CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')) DEFAULT 'queued', + error_message TEXT, + duration_seconds NUMERIC(10, 2), + created_at TIMESTAMPTZ DEFAULT NOW(), + finished_at TIMESTAMPTZ +); + +-- 6. Agent Logs (Execution Traces) +CREATE TABLE IF NOT EXISTS public.agent_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID REFERENCES public.tasks ON DELETE CASCADE, + run_id UUID REFERENCES public.task_runs ON DELETE CASCADE, + action TEXT, + content TEXT, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 7. Task Claims (Normalized Evidence) +CREATE TABLE IF NOT EXISTS public.task_claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES public.projects ON DELETE CASCADE, + task_id UUID NOT NULL REFERENCES public.tasks ON DELETE CASCADE, + claim_text TEXT NOT NULL, + claim_type TEXT CHECK (claim_type IN ('finding', 'entity_strength', 'entity_weakness', 'recommendation', 'risk', 'unknown')) DEFAULT 'finding', + entity_name TEXT, + entity_key TEXT, + claim_hash TEXT, + source_url TEXT, + confidence TEXT CHECK (confidence IN ('low', 'medium', 'high', 'unknown')) DEFAULT 'unknown', + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS task_claims_project_idx +ON public.task_claims(project_id); + +CREATE INDEX IF NOT EXISTS task_claims_task_idx +ON public.task_claims(task_id); + +CREATE INDEX IF NOT EXISTS task_claims_entity_idx +ON public.task_claims(entity_name); + +CREATE INDEX IF NOT EXISTS task_claims_entity_key_idx +ON public.task_claims(entity_key); + +CREATE UNIQUE INDEX IF NOT EXISTS task_claims_project_hash_idx +ON public.task_claims(project_id, claim_hash) +WHERE claim_hash IS NOT NULL; + +CREATE TABLE IF NOT EXISTS public.project_entity_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + canonical_name TEXT NOT NULL, + canonical_key TEXT NOT NULL, + alias TEXT NOT NULL, + alias_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(project_id, alias_key) +); + +CREATE INDEX IF NOT EXISTS project_entity_aliases_project_idx +ON public.project_entity_aliases(project_id); + +CREATE INDEX IF NOT EXISTS project_entity_aliases_canonical_key_idx +ON public.project_entity_aliases(project_id, canonical_key); + +-- 8. Project Budgets and Usage +CREATE TABLE IF NOT EXISTS public.project_budgets ( + project_id UUID PRIMARY KEY REFERENCES public.projects(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + token_budget INTEGER, + cost_budget NUMERIC(12, 4), + currency TEXT NOT NULL DEFAULT 'USD', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.project_usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + task_id UUID REFERENCES public.tasks(id) ON DELETE SET NULL, + run_id UUID REFERENCES public.task_runs(id) ON DELETE SET NULL, + agent_id UUID REFERENCES public.agents(id) ON DELETE SET NULL, + provider TEXT, + model TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost NUMERIC(12, 6) NOT NULL DEFAULT 0, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS project_usage_events_project_idx +ON public.project_usage_events(project_id); + +CREATE INDEX IF NOT EXISTS project_usage_events_task_idx +ON public.project_usage_events(task_id); + +-- 9. Worker Heartbeats +CREATE TABLE IF NOT EXISTS public.worker_heartbeats ( + worker_id TEXT PRIMARY KEY, + status TEXT CHECK (status IN ('starting', 'idle', 'processing', 'stopping', 'error')) DEFAULT 'starting', + current_task_id UUID REFERENCES public.tasks ON DELETE SET NULL, + processed_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + metadata JSONB, + started_at TIMESTAMPTZ DEFAULT NOW(), + last_seen_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 10. App Config (Global Settings) +CREATE TABLE IF NOT EXISTS public.app_config ( + key TEXT PRIMARY KEY, + value JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- RLS (Row Level Security) - Initial setup +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.agents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task_runs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.agent_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.task_claims ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.project_entity_aliases ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.project_budgets ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.project_usage_events ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.worker_heartbeats ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Service role can manage task claims" ON public.task_claims + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Task claims visible through projects" ON public.task_claims + FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = task_claims.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + )); + +CREATE POLICY "Service role can manage project entity aliases" ON public.project_entity_aliases + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project entity aliases visible through projects" ON public.project_entity_aliases + FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = project_entity_aliases.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + )); + +CREATE POLICY "Service role can manage project budgets" ON public.project_budgets + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project budgets visible through projects" ON public.project_budgets + FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = project_budgets.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + )); + +CREATE POLICY "Service role can manage project usage" ON public.project_usage_events + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +CREATE POLICY "Project usage visible through projects" ON public.project_usage_events + FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = project_usage_events.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + )); + +CREATE POLICY "Service role can manage worker heartbeats" ON public.worker_heartbeats + FOR ALL TO service_role + USING (true) + WITH CHECK (true); + +-- Basic Policies (To be refined) +-- Projects: Owners can do anything, others can read if public +CREATE POLICY "Projects visibility" ON public.projects + FOR SELECT USING (auth.uid() = owner_id OR is_public = true); + +CREATE POLICY "Projects ownership" ON public.projects + FOR ALL USING (auth.uid() = owner_id); + +-- Tasks: Protected by project ownership +CREATE POLICY "Tasks visibility" ON public.tasks + FOR SELECT USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id AND (projects.owner_id = auth.uid() OR projects.is_public = true) + )); + +CREATE POLICY "Project owners can create tasks" ON public.tasks + FOR INSERT TO authenticated WITH CHECK (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id AND projects.owner_id = auth.uid() + )); + +CREATE POLICY "Project owners can update tasks" ON public.tasks + FOR UPDATE TO authenticated USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id AND projects.owner_id = auth.uid() + )) WITH CHECK (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id AND projects.owner_id = auth.uid() + )); + +CREATE POLICY "Project owners can delete tasks" ON public.tasks + FOR DELETE TO authenticated USING (EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id AND projects.owner_id = auth.uid() + )); + +-- Agents: Marketplace templates are readable by all authenticated users. +-- Deployed agents are owned by the user who deployed them. +CREATE POLICY "Agents readable" ON public.agents + FOR SELECT TO authenticated USING (true); + +CREATE POLICY "Users can create own agents" ON public.agents + FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own agents" ON public.agents + FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own agents" ON public.agents + FOR DELETE TO authenticated USING (auth.uid() = user_id); diff --git a/database/seed.sql b/database/seed.sql new file mode 100644 index 0000000000000000000000000000000000000000..095217f031d25095337654d9d17909fdea7ab3b8 --- /dev/null +++ b/database/seed.sql @@ -0,0 +1,15 @@ +-- Seed Data for Aubm + +-- 1. Default Agents +INSERT INTO public.agents (name, role, api_provider, model, system_prompt) +VALUES +('GPT-4o', 'General Intelligence', 'openai', 'gpt-4o', 'You are a highly capable AI assistant.'), +('AMD-4o', 'Performance Specialist', 'amd', 'gpt-4o', 'You are a high-performance agent running on AMD infrastructure.'), +('Llama-3-70B', 'Fast Logic', 'groq', 'llama3-70b-8192', 'You are a fast and efficient reasoning agent.'); + +-- 2. Default App Config +INSERT INTO public.app_config (key, value) +VALUES +('output_language', '"en"'), +('max_parallel_tasks', '5'), +('enable_human_loop', 'true'); diff --git a/database/task_dependencies.sql b/database/task_dependencies.sql new file mode 100644 index 0000000000000000000000000000000000000000..2aec3e17ee0a94b18cf30d1cea43f79cb384bc14 --- /dev/null +++ b/database/task_dependencies.sql @@ -0,0 +1,104 @@ +-- Apply this migration after database/schema.sql + +CREATE TABLE IF NOT EXISTS public.task_dependencies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES public.projects ON DELETE CASCADE, + task_id UUID NOT NULL REFERENCES public.tasks ON DELETE CASCADE, + depends_on_task_id UUID NOT NULL REFERENCES public.tasks ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT task_dependencies_unique UNIQUE (project_id, task_id, depends_on_task_id), + CONSTRAINT task_dependencies_not_self CHECK (task_id <> depends_on_task_id) +); + +CREATE INDEX IF NOT EXISTS idx_task_dependencies_project_id ON public.task_dependencies(project_id); +CREATE INDEX IF NOT EXISTS idx_task_dependencies_task_id ON public.task_dependencies(task_id); +CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on_task_id ON public.task_dependencies(depends_on_task_id); + +ALTER TABLE public.task_dependencies ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'task_dependencies' + AND policyname = 'Task dependencies visibility' + ) THEN + CREATE POLICY "Task dependencies visibility" ON public.task_dependencies + FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_dependencies.project_id + AND (projects.owner_id = auth.uid() OR projects.is_public = true) + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'task_dependencies' + AND policyname = 'Project owners can create task dependencies' + ) THEN + CREATE POLICY "Project owners can create task dependencies" ON public.task_dependencies + FOR INSERT TO authenticated + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_dependencies.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'task_dependencies' + AND policyname = 'Project owners can update task dependencies' + ) THEN + CREATE POLICY "Project owners can update task dependencies" ON public.task_dependencies + FOR UPDATE TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_dependencies.project_id + AND projects.owner_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_dependencies.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'task_dependencies' + AND policyname = 'Project owners can delete task dependencies' + ) THEN + CREATE POLICY "Project owners can delete task dependencies" ON public.task_dependencies + FOR DELETE TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.projects + WHERE projects.id = task_dependencies.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; +END $$; diff --git a/database/task_owner_policies.sql b/database/task_owner_policies.sql new file mode 100644 index 0000000000000000000000000000000000000000..52bf9c5ad60f169ff5913aceddffc79cb1920391 --- /dev/null +++ b/database/task_owner_policies.sql @@ -0,0 +1,63 @@ +-- Task ownership policies for project owners +-- Apply this migration to existing Supabase projects after schema.sql. + +ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'tasks' + AND policyname = 'Project owners can create tasks' + ) THEN + CREATE POLICY "Project owners can create tasks" ON public.tasks + FOR INSERT TO authenticated WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'tasks' + AND policyname = 'Project owners can update tasks' + ) THEN + CREATE POLICY "Project owners can update tasks" ON public.tasks + FOR UPDATE TO authenticated USING ( + EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id + AND projects.owner_id = auth.uid() + ) + ) WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'tasks' + AND policyname = 'Project owners can delete tasks' + ) THEN + CREATE POLICY "Project owners can delete tasks" ON public.tasks + FOR DELETE TO authenticated USING ( + EXISTS ( + SELECT 1 FROM public.projects + WHERE projects.id = tasks.project_id + AND projects.owner_id = auth.uid() + ) + ); + END IF; +END $$; + +NOTIFY pgrst, 'reload schema'; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..f12f9601e9b41a75f5e9f62b87eed7dd163bc24e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + - ALLOWED_ORIGINS=http://localhost:80,http://localhost:5173 + volumes: + - ./backend/outputs:/app/outputs + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "80:80" + environment: + - VITE_API_URL=http://localhost:8000 + depends_on: + - backend + restart: unless-stopped diff --git a/docs/AUDIT.md b/docs/AUDIT.md new file mode 100644 index 0000000000000000000000000000000000000000..6e1c16094241dcc952f3a23f8cabf2de2ca819be --- /dev/null +++ b/docs/AUDIT.md @@ -0,0 +1,111 @@ +# Aubm Stability Audit + +Date: May 7, 2026 + +Status: Functional prototype with production hardening in progress. + +This audit intentionally uses conservative wording. Earlier docs overstated several capabilities as complete when they were scaffolded or partial. + +## Architecture + +The system uses: + +- FastAPI backend with provider-specific agent adapters. +- React/Vite frontend. +- Supabase for auth, Postgres data, RLS, and direct frontend reads. +- Queue worker support through either an embedded FastAPI worker or standalone worker processes. + +## Stable Areas + +- Project dashboard with search, filters, sorting, and progress cards. +- Project creation wizard in Guided and Expert modes. +- Agent marketplace template deployment. +- Custom agent creation. +- Task creation, assignment, dependencies, review, approval, rejection, and retry. +- Final report variants and PDF export. +- Completed project locking in the UI and backend mutation routes. +- Monitoring summary endpoint with frontend fallback. +- Sentry-compatible backend and frontend initialization. + +## Partial Areas + +### Audit Logging + +`audit_logs` schema and service exist, but coverage is not complete. The system should log every: + +- LLM call. +- Task status mutation. +- Approval/rejection. +- Retry. +- Report generation. +- Marketplace deployment. +- Admin role change. + +Current backend coverage includes task run creation, status transitions during agent execution, queue retry/terminal failure, task approval/rejection, debate start/completion/failure, project queueing, decomposition, and final report generation. The `database/add_audit_mutation_triggers.sql` migration adds table-level coverage for direct project, task, agent, and profile mutations. Remaining risk is that trigger metadata is intentionally compact; high-risk flows should still move behind backend APIs for stricter validation and richer audit context. + +### Async Worker + +`backend/worker.py` and `TaskQueueService` exist. The task schema supports `queued`, workers claim tasks through an atomic Postgres lease function, retry with exponential backoff, and report worker heartbeat metrics in Monitoring. Queue mode can start an embedded worker from FastAPI or use standalone worker processes. + +Remaining risk: + +- Queue mode is implemented but still opt-in through `TASK_EXECUTION_MODE=queue` or `use_queue=true`; direct execution remains the default for local development. + +### Real-Time Logs + +Execution logs are persisted to `agent_logs`. `GET /tasks/logs/stream` exposes a backend Server-Sent Events stream, requires a Supabase access token, supports optional `project_id` or `task_id` filters, and only returns logs for projects visible to the authenticated user. The Agent Console uses it when `VITE_API_URL` is configured, falling back to Supabase polling/realtime otherwise. Remaining risk: EventSource sends the token as a query parameter because browser EventSource cannot set custom headers; deploy behind HTTPS and avoid logging query strings. + +### Cost Control + +Project budget tables and estimated usage events are implemented. `AgentRunnerService` estimates prompt/completion tokens, blocks execution before the provider call when configured project budgets would be exceeded, and records estimated usage after successful runs. Budget status is available through `GET /projects/{project_id}/budget`, and budgets can be configured through `PUT /projects/{project_id}/budget`. Remaining risk: usage is estimated locally and pricing is only applied when `app_config.model_pricing` is configured, so this is not billing-grade reconciliation. + +### Structured Task Schemas + +`backend/services/task_schemas.py` classifies common factual, comparison, roadmap, and workflow tasks. Matching tasks receive JSON-schema-like prompt instructions, and approval is blocked when required top-level fields are missing. `backend/services/evidence_service.py` extracts structured findings and entities into `task_claims` when the claim migration exists. Extracted claims include normalized entity keys and per-project claim hashes for duplicate suppression. When `project_entity_aliases` exists, aliases are applied before hash generation so equivalent entity names can dedupe to one canonical key. + +Final reports now include an evidence summary from `task_claims`: normalized claim count, sourced claim count, source coverage, entity coverage, and sourced claims. `GET /projects/{project_id}/evidence` exposes the same normalized claims and summaries for inspection. Remaining risk: reports still also render curated task output. They do not yet require every included factual statement to come from `task_claims`, and alias merging is still heuristic rather than curated. + +### SSO + +Supabase can support OAuth externally, but Google/GitHub buttons are intentionally hidden in the current UI. The intended enterprise auth model is documented in [AUTH_MODEL.md](./AUTH_MODEL.md). Do not describe Google/GitHub SSO as enabled until a deployment explicitly configures providers and verifies redirects, role defaults, profile creation, and audit behavior. + +## Database Risks + +- Existing Supabase projects must apply migrations, not only `schema.sql`. +- PostgREST schema cache must be reloaded after schema changes. +- Some RLS files are layered; setup order matters. +- `task_dependencies` must exist for persistent dependency links. + +## Required Existing-Project Migrations + +Common migrations: + +```text +database/add_task_run_duration.sql +database/add_task_queued_status.sql +database/add_task_queue_leasing.sql +database/add_task_queue_retry_backoff.sql +database/add_worker_heartbeats.sql +database/add_audit_mutation_triggers.sql +database/add_task_claims.sql +database/add_entity_aliases.sql +database/add_profile_manager_role.sql +database/fix_profiles_rls_final.sql +database/fix_profiles_recursion.sql +database/add_team_permissions.sql +database/marketplace.sql +``` + +## Recommended Next Work + +1. Add team management UI and team-aware API paths. +2. Add explicit OAuth UI gates if Google/GitHub sign-in is reintroduced. +3. Expand audit logging coverage. +4. Move final reports toward claim-only generation for evidence-sensitive sections. +5. Add alias management UX/API, curated competitor taxonomy, and source normalization. +6. Add endpoint-level tests for completed-project locking. +7. Promote queue execution to the default after soak testing. + +## Verdict + +Aubm is usable for supervised multi-agent project workflows, but it should not be described as fully enterprise-ready until queue safety, audit coverage, evidence integrity, and auth policy are hardened. diff --git a/docs/AUTH_MODEL.md b/docs/AUTH_MODEL.md new file mode 100644 index 0000000000000000000000000000000000000000..93abaa51d18afd2fa0a59ba560c8df4f1eb2c5f3 --- /dev/null +++ b/docs/AUTH_MODEL.md @@ -0,0 +1,59 @@ +# Aubm Authentication Model + +## Current Product State + +Aubm uses Supabase Auth for user sessions. The current UI exposes email/password sign-in only. + +Google and GitHub buttons are intentionally hidden in `frontend/src/components/Login.tsx`. Supabase may still support OAuth providers at the project level, but Aubm should not advertise Google/GitHub SSO as a product feature until provider setup, role mapping, and audit behavior are tested end to end. + +## Roles + +Application roles live in `public.profiles.role`: + +- `user`: default account role. +- `manager`: operational role for managing workflows without full admin authority. +- `admin`: can manage profile roles and privileged settings. + +The final profile RLS migration protects role changes with `public.protect_profile_role()`, so non-admin users cannot elevate themselves by updating their own profile row. + +## Enterprise Auth Policy + +For production or enterprise deployments: + +- Email/password is the baseline supported sign-in flow. +- OAuth/SSO providers must be enabled deliberately per deployment. +- OAuth buttons should stay hidden unless provider configuration is documented and verified. +- New OAuth users should receive `role = 'user'` by default. +- Role elevation must happen through an admin-controlled flow, not through OAuth metadata alone. +- Team access should be handled through `teams` and `team_members`, not by making projects public. + +## Provider Requirements Before Enabling OAuth UI + +Before exposing a provider button in the frontend: + +1. Configure the provider in Supabase Auth. +2. Verify allowed redirect URLs for local, staging, and production origins. +3. Verify profile creation through `handle_new_user_profile()` or equivalent trigger. +4. Verify the profile role defaults to `user`. +5. Verify role changes are blocked for non-admin users. +6. Verify sign-in, sign-out, refresh, and expired session behavior. +7. Verify audit events for profile creation and role changes. +8. Document who owns provider credentials and rotation. + +## Recommended OAuth UI Gate + +If OAuth is reintroduced, gate it behind explicit frontend config: + +```env +VITE_ENABLE_GOOGLE_AUTH=false +VITE_ENABLE_GITHUB_AUTH=false +``` + +The UI should default to hidden buttons unless these values are explicitly enabled. + +## Operational Notes + +- Do not store provider secrets in frontend `.env`. +- Keep Supabase service role keys only in backend/server environments. +- Do not trust provider profile metadata for authorization. +- Use profile roles and team memberships as the authorization source of truth. diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..e15eeed550170465820a0184a30be121e90f861f --- /dev/null +++ b/docs/MIGRATION_GUIDE.md @@ -0,0 +1,300 @@ +# Existing Supabase Migration Guide + +Use this guide when the app code is newer than an existing Supabase database. + +Run migrations from the Supabase SQL Editor. Most files are idempotent or use `IF NOT EXISTS`, but still review them before applying in production. + +## Recommended Order + +For an existing project, apply only the migrations that match your current missing feature or error. + +### 1. Task Run Duration + +Apply when the app reports: + +```text +Could not find the 'duration_seconds' column of 'task_runs' in the schema cache +``` + +Run: + +```sql +-- database/add_task_run_duration.sql +``` + +### 2. Queued Task Status + +Apply before using `backend/worker.py` or `TaskQueueService`. + +Run: + +```sql +-- database/add_task_queued_status.sql +``` + +This allows: + +```text +tasks.status = queued +``` + +### 3. Queue Leasing + +Apply before running more than one worker, or when the worker reports that `claim_next_queued_task` is missing. + +Run: + +```sql +-- database/add_task_queue_leasing.sql +``` + +This adds queue metadata columns and the atomic claim function used by `TaskQueueService.claim_next_queued_task`. + +### 4. Worker Heartbeats + +Apply before using Monitoring to inspect worker activity. + +Run: + +```sql +-- database/add_worker_heartbeats.sql +``` + +This creates `worker_heartbeats`, used by `backend/worker.py` and `/monitoring/summary`. + +### 5. Retry Backoff + +Apply before using worker retry/backoff behavior. + +Run: + +```sql +-- database/add_task_queue_retry_backoff.sql +``` + +This adds `tasks.next_attempt_at` and updates `claim_next_queued_task` so delayed retries are not claimed too early. + +### 6. Audit Mutation Triggers + +Apply when you want direct Supabase writes from the frontend to create audit events. + +Run: + +```sql +-- database/add_audit_mutation_triggers.sql +``` + +This adds triggers for project, task, agent, and profile mutations. + +### 7. Task Claims + +Apply before using normalized claim/evidence extraction. + +Run: + +```sql +-- database/add_task_claims.sql +``` + +This creates `task_claims`, where structured task outputs can persist extracted claims, entities, normalized entity keys, claim hashes, source URLs, and confidence values. + +### 8. Entity Aliases + +Apply when extracted claims should canonicalize entity aliases before deduplication. + +Run: + +```sql +-- database/add_entity_aliases.sql +``` + +This creates `project_entity_aliases`, where project-scoped aliases can map multiple names to one canonical `entity_key`. + +### 9. Project Budgets + +Apply before using per-project token/cost budgets or budget APIs. + +Run: + +```sql +-- database/add_project_budgets.sql +``` + +This creates `project_budgets` and `project_usage_events`. Budgets are optional: projects without a budget row continue to execute normally. + +### 10. Manager Role + +Apply when admin settings need to assign `manager`. + +Run: + +```sql +-- database/add_profile_manager_role.sql +``` + +### 11. Final Profile RLS Hardening + +Apply when profile/admin policies trigger recursive RLS errors, profile creation fails with RLS, or users need to edit their own profile without being able to change `role`. + +Run: + +```sql +-- database/fix_profiles_rls_final.sql +``` + +This replaces older profile policies with owner/admin policies and adds a trigger that prevents non-admin role changes. + +If you only need the older minimal recursion fix, run: + +```sql +-- database/fix_profiles_recursion.sql +``` + +### 12. Marketplace Templates + +Apply when the Marketplace is empty or `agent_templates` does not exist. + +Run: + +```sql +-- database/marketplace.sql +``` + +### 13. Team Permissions + +Apply when projects should be shared through explicit team membership rather than only `owner_id` or `is_public`. + +Run: + +```sql +-- database/add_team_permissions.sql +``` + +This creates `teams`, `team_members`, adds `projects.team_id`, and replaces project/task policies with owner-or-team access checks. If `task_claims`, `project_entity_aliases`, `project_budgets`, or `project_usage_events` already exist, the migration also updates their read policies to use `can_view_project(project_id)` so team members can read project evidence and budget status. + +### 14. Agent Ownership + +Apply when deploying marketplace agents fails because `agents.user_id` is missing or RLS blocks inserts. + +Run: + +```sql +-- database/agent_ownership.sql +``` + +### 13. Task Dependencies + +Apply when dependency links do not persist. + +Run: + +```sql +-- database/task_dependencies.sql +``` + +## Schema Cache + +After schema changes, reload PostgREST: + +```sql +NOTIFY pgrst, 'reload schema'; +``` + +Some migration files already include this line. + +## Verification Queries + +Check task statuses: + +```sql +SELECT conname, pg_get_constraintdef(oid) +FROM pg_constraint +WHERE conrelid = 'public.tasks'::regclass + AND contype = 'c'; +``` + +Check task run duration: + +```sql +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'task_runs' + AND column_name = 'duration_seconds'; +``` + +Check profile roles: + +```sql +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'profiles'; +``` + +Check final profile RLS trigger: + +```sql +SELECT tgname +FROM pg_trigger +WHERE tgname = 'protect_profile_role_trigger'; +``` + +Check marketplace templates: + +```sql +SELECT COUNT(*) AS template_count +FROM public.agent_templates; +``` + +Check queue claim function: + +```sql +SELECT proname +FROM pg_proc +WHERE proname = 'claim_next_queued_task'; +``` + +Check worker heartbeats: + +```sql +SELECT worker_id, status, current_task_id, last_seen_at +FROM public.worker_heartbeats +ORDER BY last_seen_at DESC; +``` + +Check delayed retries: + +```sql +SELECT id, title, queue_attempts, next_attempt_at, last_error +FROM public.tasks +WHERE status = 'queued' + AND next_attempt_at > NOW() +ORDER BY next_attempt_at; +``` + +Check audit trigger installation: + +```sql +SELECT tgname +FROM pg_trigger +WHERE tgname LIKE 'audit_%_mutations' +ORDER BY tgname; +``` + +Check task claims: + +```sql +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_schema = 'public' + AND table_name = 'task_claims'; +``` + +Check team permission helpers: + +```sql +SELECT proname +FROM pg_proc +WHERE proname IN ('is_team_member', 'can_admin_team', 'can_view_project', 'can_edit_project'); +``` diff --git a/docs/OPERATING_GUIDE.md b/docs/OPERATING_GUIDE.md new file mode 100644 index 0000000000000000000000000000000000000000..22b394b9697a53b0b9d51edce29f9e828bf19a7b --- /dev/null +++ b/docs/OPERATING_GUIDE.md @@ -0,0 +1,320 @@ +# Aubm Operating Guide + +## What Aubm Does + +Aubm is an AI agent orchestration platform. Users sign in with Supabase Auth, create projects, provide context and sources, deploy or configure agents, run task workflows, review outputs, and produce reports. + +The application has three main layers: + +- `frontend/`: React + Vite dashboard for projects, marketplace, agents, debates, voice control, spatial view, monitoring, and settings. +- `backend/`: FastAPI API for task execution, project orchestration, report generation, debate orchestration, worker support, and monitoring. +- `database/`: Supabase schema, RLS policies, marketplace tables, audit tables, task dependencies, and migrations. + +## Core Runtime Flow + +1. User signs in with Supabase Auth. +2. User creates a project through the Guided or Expert wizard. +3. User optionally adds links, notes, or files as project context. +4. User deploys or creates agents. +5. User creates tasks manually or runs project orchestration to decompose the project. +6. Backend executes tasks through assigned agents. +7. Task output moves to `awaiting_approval`. +8. Human approves, rejects, retries, or reviews output. +9. Once tasks are approved, reports can be generated. +10. Full report generation marks the project `completed`; completed projects become read-only. + +## UI Modes + +Guided mode: + +- Focused workflow. +- Project creation wizard: Basics, Context, Sources, Review. +- Guided project detail panel for agents, plan, review, and finalize. + +Expert mode: + +- Full navigation. +- Project creation wizard: Basics, Context, Sources, Access, Review. +- Advanced controls for dependencies, assignments, debate, voice, spatial view, monitoring, and settings. + +## Authentication + +Aubm uses Supabase Auth. The current UI exposes email/password sign-in only; Google/GitHub buttons are hidden until a deployment explicitly enables and verifies OAuth provider behavior. See [AUTH_MODEL.md](./AUTH_MODEL.md) for the enterprise authentication policy. + +## Main Features + +### Dashboard + +Shows project cards with status and task progress. Includes search, status filter, progress filter, sorting, refresh, and project deletion. + +### Project Detail + +Supports: + +- Default agent generation. +- Manual task creation and editing. +- Agent assignment. +- Task dependency selection. +- Task filtering by status, including `queued`. +- Review, approve, reject, retry, and final report flows. +- Roadmap modal inferred from task status, priority, and dependencies. +- Read-only mode when the project is completed. + +### Agent Marketplace + +Reads `agent_templates` from Supabase and deploys selected templates into `agents`. + +Required database support: + +- `database/marketplace.sql` +- `database/agent_ownership.sql` + +### Custom Agents + +The `Agents` screen lets users create custom agents with name, role, provider, model, and system prompt. API keys stay in `backend/.env`, not in the frontend. + +### Agent Debate + +Endpoint: + +```text +POST /orchestrator/debate +``` + +Flow: + +1. Agent A generates an initial answer. +2. Agent B critiques it. +3. Agent A refines it. +4. Final debate result is saved to `tasks.output_data`. + +### Monitoring + +Endpoint: + +```text +GET /monitoring/summary +``` + +The frontend falls back to direct Supabase counts if the backend endpoint is unavailable. + +### Worker + +The worker scaffold exists: + +```powershell +cd backend +python worker.py +``` + +Existing databases must apply: + +```sql +-- database/add_task_queued_status.sql +-- database/add_task_queue_leasing.sql +-- database/add_task_queue_retry_backoff.sql +-- database/add_worker_heartbeats.sql +``` + +To use the worker for task/project execution: + +```env +TASK_EXECUTION_MODE=queue +``` + +By default, queue mode starts an embedded worker inside the FastAPI process: + +```env +TASK_QUEUE_EMBEDDED_WORKER=true +``` + +For separate worker processes, disable the embedded worker and run `python worker.py` independently. + +Or opt in per request: + +```text +POST /tasks/{task_id}/run?use_queue=true +POST /orchestrator/projects/{project_id}/run?use_queue=true +``` + +## Backend Setup + +```powershell +cd backend +python -m venv venv +.\venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +Required `backend/.env` values: + +```env +SUPABASE_URL=... +SUPABASE_SERVICE_ROLE_KEY=... +``` + +Optional provider and monitoring values: + +```env +OPENAI_API_KEY=... +GROQ_API_KEY=... +GEMINI_API_KEY=... +AMD_API_KEY=... +TAVILY_API_KEY=... +SENTRY_DSN=... +``` + +## Frontend Setup + +```powershell +cd frontend +npm install +npm run dev +``` + +Required `frontend/.env` values: + +```env +VITE_SUPABASE_URL=... +VITE_SUPABASE_ANON_KEY=... +VITE_API_URL=http://127.0.0.1:8000 +``` + +## Database Setup Order + +Fresh project: + +1. `database/schema.sql` +2. `database/seed.sql` +3. `database/phase3_updates.sql` +4. `database/marketplace.sql` +5. `database/enterprise_security.sql` +6. `database/add_team_permissions.sql` +7. `database/agent_ownership.sql` +8. `database/task_owner_policies.sql` +9. `database/default_agents.sql` + +Common existing-project migrations: + +- `database/add_task_run_duration.sql` +- `database/add_task_queued_status.sql` +- `database/add_task_queue_leasing.sql` +- `database/add_task_queue_retry_backoff.sql` +- `database/add_worker_heartbeats.sql` +- `database/add_audit_mutation_triggers.sql` +- `database/add_task_claims.sql` +- `database/add_entity_aliases.sql` +- `database/add_project_budgets.sql` +- `database/add_profile_manager_role.sql` +- `database/fix_profiles_rls_final.sql` +- `database/fix_profiles_recursion.sql` +- `database/add_team_permissions.sql` + +For a guided checklist, see [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md). + +## Project Budgets + +Apply `database/add_project_budgets.sql` before using budget APIs. + +Budget endpoints: + +- `GET /orchestrator/projects/{project_id}/budget` +- `PUT /orchestrator/projects/{project_id}/budget` + +Example payload: + +```json +{ + "enabled": true, + "token_budget": 500000, + "cost_budget": 25, + "currency": "USD" +} +``` + +Cost estimates use `app_config.model_pricing` when configured. Without pricing config, token budgets still block execution and cost estimates remain `0`. + +## Agent Log Streaming + +The backend exposes: + +```text +GET /tasks/logs/stream +``` + +Optional filters: + +```text +GET /tasks/logs/stream?access_token= +GET /tasks/logs/stream?access_token=&project_id= +GET /tasks/logs/stream?access_token=&task_id= +``` + +The stream uses Server-Sent Events and emits: + +- `ready`: stream connection established. +- `log`: an `agent_logs` row. +- `error`: backend could not fetch logs. + +The frontend Agent Console uses this stream when `VITE_API_URL` is configured. If the stream is unavailable, it falls back to Supabase polling/realtime. + +## Common Errors + +### `Could not find the 'duration_seconds' column of 'task_runs' in the schema cache` + +Apply: + +```sql +-- database/add_task_run_duration.sql +``` + +### `new row for relation "tasks" violates check constraint` + +If the value is `queued`, apply: + +```sql +-- database/add_task_queued_status.sql +``` + +If the worker cannot find `claim_next_queued_task`, also apply: + +```sql +-- database/add_task_queue_leasing.sql +``` + +If Monitoring cannot read worker heartbeat data, apply: + +```sql +-- database/add_worker_heartbeats.sql +``` + +### Marketplace shows no templates + +Apply: + +```sql +-- database/marketplace.sql +``` + +### Recursive profiles policy error + +Apply: + +```sql +-- database/fix_profiles_rls_final.sql +``` + +For the older minimal fix: + +```sql +-- database/fix_profiles_recursion.sql +``` + +## Development Rules + +- Keep application UI text in English. +- Keep technical documentation in English. +- Keep migrations idempotent when possible. +- Do not commit real secrets. +- Prefer separate migration files for database changes. diff --git a/docs/SALES_ONE_PAGER.md b/docs/SALES_ONE_PAGER.md new file mode 100644 index 0000000000000000000000000000000000000000..e203464627898fbac770ea7ca8163512269194d7 --- /dev/null +++ b/docs/SALES_ONE_PAGER.md @@ -0,0 +1,148 @@ +# Aubm + +## Descripcion + +Aubm es una plataforma de orquestacion de agentes de IA disenada para convertir objetivos complejos en trabajo ejecutable, supervisable y trazable. + +Permite crear proyectos, definir contexto, cargar fuentes, generar agentes especializados, descomponer objetivos en tareas, asignarlas, establecer dependencias y ejecutar flujos de trabajo con revision humana en el circuito. En lugar de usar un solo chat aislado, Aubm organiza el trabajo como un sistema operativo para equipos y agentes: planifica, coordina, monitorea y consolida resultados. + +## Propuesta de valor + +- Orquestacion multiagente para investigacion, analisis, planificacion y ejecucion +- Human-in-the-loop para aprobar, corregir o relanzar resultados antes de cerrar trabajo +- Soporte multi-LLM para operar con distintos proveedores y modelos +- Gestion estructurada de proyectos con tareas, dependencias, asignacion por agente y reportes +- Contexto enriquecido con links, notas, documentos y busqueda web +- Dos experiencias de uso: + - Guided para equipos que quieren velocidad y simplicidad + - Expert para usuarios que necesitan control fino + +## Problema que resuelve + +La mayoria de los equipos usa IA como conversaciones sueltas, sin estructura operativa, sin trazabilidad y sin mecanismos claros de supervision. Eso genera: + +- informacion dispersa +- baja reutilizacion del contexto +- dificultad para coordinar multiples agentes o tareas +- poca gobernanza sobre resultados +- debilidad para convertir analisis en ejecucion real + +Aubm resuelve eso llevando la IA desde el chat aislado a un flujo de trabajo estructurado y auditable. + +## Potenciales clientes + +### 1. Equipos de estrategia y research + +Ideal para equipos que necesitan investigar mercados, competidores, tendencias, categorias y oportunidades con rapidez, pero sin perder control ni trazabilidad. + +Ejemplos: + +- consultoras +- equipos de estrategia corporativa +- research interno de producto +- intelligence teams + +### 2. Equipos de operaciones y automatizacion interna + +Util para organizaciones que quieren coordinar procesos internos con IA, documentar decisiones, asignar trabajo y supervisar ejecucion. + +Ejemplos: + +- operaciones +- PMO +- equipos de mejora de procesos +- transformation offices + +### 3. Product teams y builders de IA + +Sirve para equipos que construyen experiencias con LLMs y necesitan orquestar agentes, herramientas, tareas y revisiones dentro de una misma interfaz. + +Ejemplos: + +- startups AI-native +- equipos de producto +- equipos de platform engineering +- labs de innovacion + +### 4. Equipos de analisis competitivo + +Especialmente util para areas que monitorean el mercado y necesitan producir comparativas, matrices, gaps de producto y recomendaciones accionables. + +Ejemplos: + +- product marketing +- strategy +- business development +- founders y leadership teams + +### 5. Organizaciones que generan reportes ejecutivos + +Aubm ayuda a transformar inputs dispersos en entregables consistentes, revisables y listos para decision. + +Ejemplos: + +- equipos de direccion +- chief of staff +- operaciones comerciales +- areas de reporting ejecutivo + +### 6. Empresas que quieren coordinacion humano + IA + +Buen fit para companias que no quieren automatizacion ciega, sino un modelo donde la IA trabaja con supervision humana, aprobaciones y control de calidad. + +Ejemplos: + +- empresas reguladas +- fintech +- healthtech +- legaltech +- enterprise SaaS con procesos sensibles + +## Buyer personas + +### Founder / CEO + +Busca velocidad de ejecucion, claridad operativa y capacidad de convertir analisis en decisiones concretas. + +### Head of Strategy + +Necesita investigacion estructurada, comparativas reproducibles y reportes confiables para direccion. + +### COO / Operations Lead + +Quiere procesos mas ordenados, seguimiento de tareas, dependencias claras y visibilidad sobre ejecucion. + +### Product Manager / Head of Product + +Busca coordinar exploracion, definicion, research, roadmap y analisis competitivo usando IA de forma estructurada. + +### Innovation Lead / AI Lead + +Necesita una capa operativa para agentes de IA que permita experimentar, monitorear y gobernar flujos multiagente. + +## Casos de uso comerciales + +- analisis competitivo +- research de mercado +- generacion de roadmap +- descomposicion de proyectos complejos +- coordinacion de agentes especialistas +- produccion de reportes ejecutivos +- automatizacion supervisada de flujos internos +- investigacion con fuentes y busqueda web + +## Diferenciales + +- no es solo chat: es ejecucion estructurada +- no es solo automatizacion: incorpora supervision humana +- no depende de un solo modelo: opera con multiples proveedores +- no se limita al analisis: tambien organiza tareas, dependencias y resultados +- puede servir tanto a usuarios nuevos como avanzados con sus modos Guided y Expert + +## Mensaje corto para venta + +Aubm convierte la IA de conversaciones sueltas en ejecucion real: proyectos, agentes, tareas, supervision y resultados en una sola plataforma. + +## Pitch corto + +Aubm es la capa operativa para equipos que quieren trabajar con agentes de IA sin perder control. Organiza objetivos, contexto, tareas, agentes, dependencias y aprobaciones dentro de un flujo supervisable y trazable, listo para investigacion, operaciones, analisis competitivo y reportes ejecutivos. diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000000000000000000000000000000000000..bf504585305a6023759291671e21b9c263b33124 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,122 @@ +# Aubm Implementation Tasks + +This file tracks implementation work against [ROADMAP.md](../ROADMAP.md) and [SPEC.md](../SPEC.md). Status is conservative. + +Legend: + +- `[x]` Completed +- `[/]` Partial or in progress +- `[ ]` Pending + +## Completed Foundation + +- [x] Create backend, frontend, and database directories. +- [x] Implement FastAPI backend entrypoint. +- [x] Implement React/Vite frontend shell. +- [x] Add Supabase Auth integration. +- [x] Add baseline Supabase schema and RLS policies. +- [x] Implement provider-based agent factory. +- [x] Implement project dashboard. +- [x] Implement project detail task list and task forms. +- [x] Implement task approval and rejection. +- [x] Implement final report, brief, pessimistic analysis, and PDF export. +- [x] Implement completed-project locking in frontend and backend mutation endpoints. + +## Product Workflow + +- [x] Add Guided and Expert UI modes. +- [x] Add project creation wizard for Guided mode. +- [x] Add project creation wizard for Expert mode. +- [x] Add project source inputs: links, notes, and file references. +- [x] Add dashboard search, filters, and sorting. +- [x] Add project roadmap modal inferred from tasks. +- [x] Add retry handling for failed/error-output tasks. +- [x] Add legal example projects. +- [x] Add dashboard card alignment fixes. + +## Agents and Marketplace + +- [x] Add custom agent management UI. +- [x] Add marketplace table and seed templates. +- [x] Add marketplace search and deploy flow. +- [x] Prevent duplicate template deploys per user where possible. +- [x] Add default agents flow. +- [ ] Add richer marketplace categories, filters, and template detail pages. + +## Security and Roles + +- [x] Add profile roles: `user`, `manager`, `admin`. +- [x] Add admin user management support for manager role. +- [x] Hide Google/GitHub auth buttons in the current login UI. +- [x] Fix recursive profile admin policies with SECURITY DEFINER helper. +- [x] Add final profile RLS hardening with owner/admin policies and role-protection trigger. +- [/] Expand audit logging coverage across all LLM and workflow events. +- [x] Add audit events for task run, queue, retry, approval, debate, decomposition, and report generation paths. +- [x] Add audit trigger migration for direct project, task, agent, and profile mutations. +- [ ] Move direct frontend mutations behind backend APIs where stricter authorization or validation is required. +- [x] Add team permission migration with teams, team members, project team ownership, and project/task RLS helpers. +- [x] Extend team permission migration to make `task_claims` visible through project access when the evidence table exists. +- [ ] Add team management UI and team-aware project assignment flows. +- [x] Define enterprise auth policy before exposing OAuth buttons again. +- [ ] Replace broad team/security claims with tested team membership flows. + +## Queue and Scale + +- [x] Add `backend/worker.py` scaffold. +- [x] Add `backend/services/task_queue.py`. +- [x] Add `queued` status support to `tasks`. +- [x] Add `database/add_task_queued_status.sql`. +- [x] Add queue leasing metadata and `claim_next_queued_task`. +- [x] Update worker to use atomic task claiming. +- [x] Add worker heartbeat table and monitoring counts. +- [x] Show queued, running, active workers, and stale leases in Monitoring. +- [x] Add retry delay/backoff with `next_attempt_at`. +- [x] Show delayed retries in Monitoring. +- [x] Add queue execution mode to task and project run endpoints. +- [x] Start an embedded worker from FastAPI when queue mode is enabled. +- [x] Store queue attempts and terminal failure reason. +- [ ] Make queue execution the default after worker retry/backoff is hardened. + +## Data Quality and Evidence + +- [x] Add heuristic output guardrails. +- [x] Add final-report filtering for low-quality or placeholder sections. +- [/] Require source URLs heuristically for sensitive factual claims. +- [/] Add strict JSON task schemas per task type. +- [x] Add task schema classifier, prompt instructions, and approval gate for structured outputs. +- [x] Add claim table or normalized evidence model. +- [/] Add mandatory `source_url` for competitor, pricing, benchmark, release, market, and revenue claims. +- [x] Extract structured findings/entities into `task_claims`. +- [/] Add entity normalization and alias merging. +- [x] Add normalized `entity_key` for extracted task claims. +- [x] Add project-scoped entity alias table and canonicalize extracted claim entity keys before hashing. +- [ ] Add alias management UX/API and curated competitor taxonomy. +- [/] Add semantic deduplication. +- [x] Add normalized claim hashes to dedupe repeated extracted claims per project. +- [/] Build evidence-aware final report from validated claims only. +- [x] Add normalized evidence summary to final reports from `task_claims`. +- [x] Add project evidence API with normalized claims, source coverage, and entity/type summaries. + +## Intelligence and Memory + +- [x] Add project budget and usage tables. +- [x] Add budget service with estimated token/cost accounting and pre-run blocking. +- [x] Add project budget API endpoints. +- [x] Prevent queued budget-blocked tasks from retrying as transient worker failures. +- [ ] Replace estimated usage with provider-native token usage where available. +- [ ] Add billing-grade pricing reconciliation. +- [x] Add backend SSE stream for agent logs. +- [x] Connect Agent Console to backend SSE stream with Supabase polling/realtime fallback. +- [x] Add project/task-scoped log stream filtering. +- [x] Add auth-aware log stream subscriptions. + +## Documentation + +- [x] Update ROADMAP.md with conservative status. +- [x] Update README.md. +- [x] Update SPEC.md. +- [x] Update operating guide. +- [x] Update task tracker. +- [x] Add a migration guide for existing Supabase projects. +- [x] Add enterprise authentication model documentation. +- [ ] Add API endpoint reference. diff --git a/docs/TASK_SCHEMAS.md b/docs/TASK_SCHEMAS.md new file mode 100644 index 0000000000000000000000000000000000000000..3d2198d66970a09ecabfab10be2e3663e14401a6 --- /dev/null +++ b/docs/TASK_SCHEMAS.md @@ -0,0 +1,95 @@ +# Task Output Schemas + +Aubm now applies lightweight task-schema validation before approval and final reporting. + +The backend classifies tasks by title, description, and project context. When a task matches one of the structured categories, the agent prompt asks for valid JSON and `quality_review.schema_review` records validation results. + +## Schema Types + +### `factual_research` + +Used for research, market, pricing, revenue, release, source, evidence, and audit tasks. + +Required fields: + +- `summary` +- `findings` + +Expected shape: + +```json +{ + "summary": "string", + "findings": [ + { + "claim": "string", + "source_url": "string or null", + "confidence": "low | medium | high" + } + ], + "unknowns": ["string"] +} +``` + +### `comparison` + +Used for competitor, comparison, matrix, benchmark, and SWOT tasks. + +Required fields: + +- `summary` +- `entities` + +Expected shape: + +```json +{ + "summary": "string", + "entities": [ + { + "name": "string", + "category": "string", + "strengths": ["string"], + "weaknesses": ["string"], + "source_url": "string or null" + } + ], + "differentiators": ["string"], + "gaps": ["string"] +} +``` + +### `roadmap` + +Used for roadmap, recommendation, priority, timeline, and planning tasks. + +Required fields: + +- `summary` +- `recommendations` + +### `workflow_design` + +Used for workflow, process, design, architecture, implementation, and controls tasks. + +Required fields: + +- `summary` +- `steps` + +## Approval Behavior + +If a structured task does not return JSON matching its required top-level fields, approval is blocked by the existing task quality gate. + +For `factual_research` and `comparison`, each finding/entity should include a valid `source_url`. Missing source URLs block approval because those outputs are used for evidence-sensitive reporting. + +Structured findings, comparison entities, recommendations, and risks are extracted into `public.task_claims` when `database/add_task_claims.sql` has been applied. If that table is missing, task execution continues and the backend logs a warning. + +Extracted claims include: + +- `entity_key`: a normalized ASCII/lowercase key for entity matching. +- `claim_hash`: a normalized per-project hash for duplicate suppression. + +When `database/add_entity_aliases.sql` has been applied, `project_entity_aliases` can map multiple normalized aliases to one canonical `entity_key` before claim hashes are calculated. This improves deduplication for equivalent entity names such as legal suffix variants. + +Final reports include an evidence summary when normalized claims are available: claim counts, sourced claim counts, source coverage, entity coverage, and sourced claim excerpts. The same normalized evidence is available through `GET /projects/{project_id}/evidence`. The remaining roadmap step is claim-only report generation for evidence-sensitive sections. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..a2a665d7a0e351e4f1d77a6a0bbdd1a5f7e2d9ec --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +VITE_API_URL=http://localhost:8000 +VITE_SUPABASE_URL=https://your-project-id.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key-here + +# Optional: Sentry +VITE_SENTRY_DSN=your-sentry-dsn diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2e51b702a17e1c2466e3c11fe0e18c57695d7598 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,44 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# Python virtual env +venv/ +backend/venv/ +**/venv/ + +# Python cache +__pycache__/ +*.pyc +*.pyd + +# Env files +.env +.env.* +**/.env +**/.env.* + +# Permitir ejemplos sin secretos +!.env.example +!**/.env.example + diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..6ca79682d42468d40b0f6be81d6e4facebbe6d36 --- /dev/null +++ b/frontend/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "06cd0f54", + "configHash": "6f67514f", + "lockfileHash": "7cb592dd", + "browserHash": "15e48278", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json new file mode 100644 index 0000000000000000000000000000000000000000..3dbc1ca591c0557e35b6004aeba250e6a70b56e3 --- /dev/null +++ b/frontend/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..55c152a3050e1f265a98a5c518b75139a528c7ea --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-slim AS build-stage + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# Production stage +FROM nginx:stable-alpine AS production-stage + +COPY --from=build-stage /app/dist /usr/share/nginx/html +COPY --from=build-stage /app/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..eddd45ab47fc2fd840845aa4f5573c41b948704e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,46 @@ +# Aubm Frontend + +React + Vite + TypeScript frontend for Aubm. + +## Main Screens + +- Dashboard: projects, search, filters, sorting, progress. +- New Project: Guided and Expert wizard. +- Project Detail: tasks, assignments, dependencies, reports, roadmap, completed-project lock state. +- Marketplace: search and deploy agent templates. +- Agents: create and manage custom agents. +- Debate: start multi-agent review flows. +- Voice Control: browser speech navigation and status. +- Spatial View: DAG-style task visualization. +- Monitoring: backend health and Supabase fallback metrics. +- Settings: UI mode, provider defaults, and admin role management. + +## Setup + +```powershell +npm install +npm run dev +``` + +Create `.env`: + +```env +VITE_API_URL=http://127.0.0.1:8000 +VITE_SUPABASE_URL=your_project_url +VITE_SUPABASE_ANON_KEY=your_anon_key +VITE_SENTRY_DSN=optional_dsn +``` + +## Validation + +```powershell +npm run lint +npm run build +``` + +## Notes + +- UI text should remain in English. +- Guided mode hides advanced surfaces. +- Expert mode exposes marketplace, debate, voice, spatial view, monitoring, and admin settings. +- Completed projects are read-only: reports and output review remain available, but task mutations are disabled. diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa18ce75cc3cdf9f255b88cc02f890229ed556dd --- /dev/null +++ b/frontend/capacitor.config.ts @@ -0,0 +1,9 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.aubm.app', + appName: 'Aubm', + webDir: 'dist' +}; + +export default config; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..c2a386b181c6464d931bb24f298b9fd085ae3000 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,25 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + rules: { + 'react-hooks/set-state-in-effect': 'off', + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..42bcd27ffa5e0759245a27769da8b70429385fb6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + frontend + + +
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..80fcbc792ecb28fbfadd9e225dcc9dd01f2afc4b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the backend if needed + # location /api/ { + # proxy_pass http://backend:8000/; + # } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..7cb144a339fff7e43f27837445bd9289a7cad059 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4442 @@ +{ + "name": "frontend", + "version": "0.7.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.7.0", + "dependencies": { + "@capacitor/android": "^8.3.1", + "@capacitor/cli": "^7.6.2", + "@capacitor/core": "^8.3.1", + "@capacitor/ios": "^8.3.1", + "@sentry/react": "^8.54.0", + "@supabase/supabase-js": "^2.105.1", + "framer-motion": "^12.38.0", + "i18next": "^26.0.8", + "lucide-react": "^1.14.0", + "react-i18next": "^17.0.6", + "react-router-dom": "^7.1.5", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^6.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capacitor/android": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz", + "integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.3.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.6.2.tgz", + "integrity": "sha512-uPm+GDVhdWrM/DBWZ/L6c8uBVaEcge4MAXhqrIJWSkwad/9vNoVfUjtHaVgXxPE1g399PhlGm4kU8U7Qdfmwow==", + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@capacitor/core": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz", + "integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz", + "integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.3.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/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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/@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.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.2.tgz", + "integrity": "sha512-GnKod+gL/Y+1FUM/RGV8q6le1CoyiGbT40MitEK7eVwWe+bfTRq1gN7ioupyHFMUg1RlQkDQ4/sENmio/uow5A==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.2.tgz", + "integrity": "sha512-XQy//NWbL0mLLM5w8wNDWMNpXz39VUyW2397dUrH8++kR63WhUVAvTOtL0o0GMVadSAzl1b08oHP9zSUNFQwcg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.55.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.2.tgz", + "integrity": "sha512-+W43Z697EVe/OgpGW07B773sa8xO1UbpnW0Cr+E+3FMDb6ZbXlaBUoagPTUkkQPdwBe35SDh6r8y2M3EOPGbxg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.55.2", + "@sentry/core": "8.55.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.2.tgz", + "integrity": "sha512-P/jGiuR7dRLG9IzD/463fLgiibyYceauav/9prRG0ZxJm1AtuO02OKball2Fs3bbzdzwHCTlcsUuL2ivDF4b5A==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.55.2", + "@sentry/core": "8.55.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/browser": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.2.tgz", + "integrity": "sha512-xHuPIEKhx9zw5quWvv4YgZprnwoVMCfxIhmOIf6KJ9iizyUHeUDcKpLS59xERroqwX4RpvK+l/27AZu4zfZlzQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.55.2", + "@sentry-internal/feedback": "8.55.2", + "@sentry-internal/replay": "8.55.2", + "@sentry-internal/replay-canvas": "8.55.2", + "@sentry/core": "8.55.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/core": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.2.tgz", + "integrity": "sha512-YlEBwybUcOQ/KjMHDmof1vwweVnBtBxYlQp7DE3fOdtW4pqqdHWTnTntQs4VgYfxzjJYgtkd9LHlGtg8qy+JVQ==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/react": { + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.55.2.tgz", + "integrity": "sha512-1TPfKZYkJal2Dyt2W0tf1roOZmu7sqr6/dTqjdsuu2WgGTilMEreK26YqB8ROOYdMjkVJpNCcIKXQHyMp2eCwA==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "8.55.2", + "@sentry/core": "8.55.2", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.1.tgz", + "integrity": "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.1.tgz", + "integrity": "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.1.tgz", + "integrity": "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.1.tgz", + "integrity": "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.1", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.1.tgz", + "integrity": "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz", + "integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.105.1", + "@supabase/functions-js": "2.105.1", + "@supabase/postgrest-js": "2.105.1", + "@supabase/realtime-js": "2.105.1", + "@supabase/storage-js": "2.105.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "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/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "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/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "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/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "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/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "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/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", + "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "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/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-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "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/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "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/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "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/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-i18next": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", + "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "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-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-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "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/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "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/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "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/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d24fe602e51f806ede1e7f7757c605cbe34f85aa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "frontend", + "private": true, + "version": "0.7.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@capacitor/android": "^8.3.1", + "@capacitor/cli": "^7.6.2", + "@capacitor/core": "^8.3.1", + "@capacitor/ios": "^8.3.1", + "@supabase/supabase-js": "^2.105.1", + "framer-motion": "^12.38.0", + "i18next": "^26.0.8", + "lucide-react": "^1.14.0", + "react-i18next": "^17.0.6", + "react-router-dom": "^7.1.5", + "@sentry/react": "^8.54.0", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^6.4.2" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..6893eb13237060adc0c968a690149a49faa2d7d3 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..e9522193d9f796a9748e9ad8c952a5df73c87db9 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/runtime-config.js b/frontend/public/runtime-config.js new file mode 100644 index 0000000000000000000000000000000000000000..e7418b60147ac96e79948921babf0a95fafffc6a --- /dev/null +++ b/frontend/public/runtime-config.js @@ -0,0 +1 @@ +window.__AUBM_CONFIG__ = {}; diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..f90339d8f765fa2c69d9a341959a8ddb9fff5720 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96e27936631cc65945422e7c15f4b3b276a42ab9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,300 @@ +import React, { useState } from 'react'; +import { + Bot, + LayoutDashboard, + Settings, + PlusCircle, + Menu, + X, + LogOut, + MessageSquare, + ShoppingBag, + Volume2, + Box, + Activity, + Users, + ShieldCheck +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useAuth } from './context/useAuth'; +import Login from './components/Login'; +import DebateView from './components/DebateView'; +import Marketplace from './components/Marketplace'; +import VoiceControl from './components/VoiceControl'; +import SpatialDashboard from './components/SpatialDashboard'; +import MonitoringView from './components/MonitoringView'; +import NewProject from './components/NewProject'; +import SettingsView from './components/SettingsView'; +import Dashboard from './components/Dashboard'; +import ProjectDetail from './components/ProjectDetail'; +import AgentsView from './components/AgentsView'; +import AgentConsole from './components/AgentConsole'; +import SplashScreen from './components/SplashScreen'; +import TeamsView from './components/TeamsView'; +import AuditView from './components/AuditView'; +import { useEffect } from 'react'; +import { getUiMode, saveUiMode } from './services/uiMode'; +import type { UiMode } from './services/uiMode'; +import { getAppVersion } from './services/runtimeConfig'; + +type AppTab = 'dashboard' | 'project-detail' | 'agents' | 'marketplace' | 'debate' | 'voice' | 'spatial' | 'monitoring' | 'teams' | 'audit' | 'new-project' | 'settings'; + +const App: React.FC = () => { + const { session, loading, signOut, profile, user } = useAuth(); + const appVersion = getAppVersion(); + const [activeTab, setActiveTab] = useState('dashboard'); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [initialTaskId, setInitialTaskId] = useState(null); + const [projectDetailReturnTab, setProjectDetailReturnTab] = useState('dashboard'); + const [initialProjectData, setInitialProjectData] = useState(null); + const [uiMode, setUiMode] = useState(() => getUiMode()); + const [isSidebarOpen, setIsSidebarOpen] = useState(() => typeof window === 'undefined' || window.innerWidth >= 900); + const [showSplash, setShowSplash] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => setShowSplash(false), 2500); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (uiMode === 'expert') return; + if (['agents', 'marketplace', 'debate', 'voice', 'spatial', 'monitoring'].includes(activeTab)) { + setActiveTab('dashboard'); + } + }, [activeTab, uiMode]); + + const navigateTo = (tab: AppTab) => { + setActiveTab(tab); + if (typeof window !== 'undefined' && window.innerWidth < 900) { + setIsSidebarOpen(false); + } + }; + + const openProjectDetail = (projectId: string, options?: { taskId?: string | null; returnTab?: AppTab }) => { + setSelectedProjectId(projectId); + setInitialTaskId(options?.taskId ?? null); + setProjectDetailReturnTab(options?.returnTab ?? 'dashboard'); + navigateTo('project-detail'); + }; + + const updateUiMode = (mode: UiMode) => { + setUiMode(mode); + saveUiMode(mode); + }; + + if (loading || showSplash) return ; + if (!session) return ; + + return ( +
+ {isSidebarOpen && +
+ + + +
+
+
+ {(profile?.full_name || user?.email || 'U').slice(0, 2).toUpperCase()} +
+
+
{profile?.full_name || user?.email || 'User'}
+
{profile?.role || 'user'}
+
+
+
+ Version {appVersion} +
+
+ + )} + + + {/* Main Content */} +
+
+ + +
+
+ + API Online +
+
+ {uiMode === 'guided' ? 'Guided Mode' : 'Expert Mode'} +
+
+
+ +
+ {activeTab === 'dashboard' && ( + { + setInitialProjectData(data || null); + navigateTo('new-project'); + }} + onOpenProject={(projectId) => openProjectDetail(projectId)} + /> + )} + {activeTab === 'project-detail' && selectedProjectId && ( + { + setInitialTaskId(null); + navigateTo(projectDetailReturnTab); + }} + /> + )} + + {activeTab === 'debate' && uiMode === 'expert' && } + {activeTab === 'agents' && uiMode === 'expert' && } + {activeTab === 'marketplace' && uiMode === 'expert' && } + {activeTab === 'voice' && uiMode === 'expert' && } + {activeTab === 'spatial' && uiMode === 'expert' && ( + setSelectedProjectId(projectId)} + onOpenTask={(projectId, taskId) => openProjectDetail(projectId, { taskId, returnTab: 'spatial' })} + /> + )} + {activeTab === 'monitoring' && uiMode === 'expert' && } + {activeTab === 'teams' && uiMode === 'expert' && } + {activeTab === 'audit' && uiMode === 'expert' && } + {activeTab === 'new-project' && { setInitialProjectData(null); navigateTo('dashboard'); }} />} + {activeTab === 'settings' && } +
+ + {/* Real-time Agent Console */} + +
+ + ); +}; + +const SidebarItem: React.FC<{ icon: React.ReactNode, label: string, active?: boolean, onClick: () => void }> = ({ icon, label, active, onClick }) => ( + +); + +export default App; diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..5101b674df391399da71c767aa5c976426c9dc7a --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AgentConsole.tsx b/frontend/src/components/AgentConsole.tsx new file mode 100644 index 0000000000000000000000000000000000000000..45b1ead117e29e24d325cef80610576e87fa0967 --- /dev/null +++ b/frontend/src/components/AgentConsole.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Terminal } from 'lucide-react'; +import { supabase } from '../services/supabase'; +import { getApiUrl } from '../services/runtimeConfig'; + +interface LogEntry { + id: string; + created_at: string; + action: string; + content: string; + task_id: string | null; +} + +interface AgentConsoleProps { + projectId?: string | null; + taskId?: string | null; +} + +const AgentConsole: React.FC = ({ projectId, taskId }) => { + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + + useEffect(() => { + const appendLog = (newLog: LogEntry) => { + setLogs(prev => { + if (prev.some(l => l.id === newLog.id)) return prev; + return [...prev, newLog].slice(-50); + }); + }; + + const fetchLogs = async () => { + const { data, error: supabaseError } = await supabase + .from('agent_logs') + .select('*') + .order('created_at', { ascending: false }) + .limit(50); + + if (supabaseError) { + console.error('Error fetching logs:', supabaseError); + setError(supabaseError.message); + return; + } + + setError(null); + if (data) { + setLogs(data.reverse()); + } + }; + + const apiUrl = getApiUrl(); + let eventSource: EventSource | null = null; + let channel: ReturnType | null = null; + let pollInterval: number | null = null; + let active = true; + + const connectBackendStream = async () => { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!active || !accessToken) { + setError('Authenticated log stream unavailable. Please refresh manually for latest logs.'); + fetchLogs(); + return; + } + const params = new URLSearchParams(); + params.set('access_token', accessToken); + if (taskId) { + params.set('task_id', taskId); + } else if (projectId) { + params.set('project_id', projectId); + } + const query = params.toString(); + eventSource = new EventSource(`${apiUrl}/tasks/logs/stream${query ? `?${query}` : ''}`); + eventSource.addEventListener('ready', () => setError(null)); + eventSource.addEventListener('log', (event) => { + try { + appendLog(JSON.parse((event as MessageEvent).data) as LogEntry); + setError(null); + } catch (parseError) { + console.error('Error parsing log stream event:', parseError); + } + }); + eventSource.addEventListener('error', () => { + setError('Backend log stream disconnected. Polling disabled to save resources.'); + // fetchLogs(); // Manual fetch only or auto-reconnect logic without tight loops + }); + }; + + if (apiUrl) { + connectBackendStream(); + } else { + fetchLogs(); + pollInterval = window.setInterval(() => { + if (document.visibilityState === 'visible') fetchLogs(); + }, 15000); + + channel = supabase + .channel('agent_logs_changes') + .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'agent_logs' }, (payload) => { + appendLog(payload.new as LogEntry); + }) + .subscribe(); + } + + return () => { + active = false; + if (eventSource) eventSource.close(); + if (pollInterval) window.clearInterval(pollInterval); + if (channel) supabase.removeChannel(channel); + }; + }, [projectId, taskId]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + const formatTimestamp = (ts: string) => { + const date = new Date(ts); + return date.toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + }; + + return ( +
+
+ + Agent Console +
+
+ {error && ( +
+ [ERROR] {error}. This might be due to Supabase RLS policies. +
+ )} + {logs.length === 0 && !error &&
[System] Waiting for logs...
} + {logs.map((log) => ( +
+ [{formatTimestamp(log.created_at)}] + [{log.action.toUpperCase()}] + {log.content} +
+ ))} +
+
+ ); +}; + +export default AgentConsole; diff --git a/frontend/src/components/AgentsView.tsx b/frontend/src/components/AgentsView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0f1d1f441fc0bde4a9bc40d29f8067ffc1bf4f7c --- /dev/null +++ b/frontend/src/components/AgentsView.tsx @@ -0,0 +1,359 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Bot, CheckCircle2, PlusCircle, RefreshCw, X, ShoppingBag } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { supabase } from '../services/supabase'; +import { useAuth } from '../context/useAuth'; +import { getDefaultModel, getDefaultProvider, providerOptions } from '../services/llmConfig'; +import type { SupportedProvider } from '../services/llmConfig'; + +interface Agent { + id: string; + name: string; + role: string | null; + api_provider: SupportedProvider; + model: string; + system_prompt: string | null; + created_at: string; +} + +const AgentsView: React.FC = () => { + const { user } = useAuth(); + const defaultProvider = useMemo(() => getDefaultProvider(), []); + const [agents, setAgents] = useState([]); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [name, setName] = useState(''); + const [role, setRole] = useState(''); + const [provider, setProvider] = useState(defaultProvider); + const [model, setModel] = useState(getDefaultModel(defaultProvider)); + const [systemPrompt, setSystemPrompt] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [showShareModal, setShowShareModal] = useState(false); + const [sharingAgent, setSharingAgent] = useState(null); + const [shareToTeam, setShareToTeam] = useState(null); + const [shareDescription, setShareDescription] = useState(''); + const [shareCategory, setShareCategory] = useState('General'); + const [isPublicTemplate, setIsPublicTemplate] = useState(false); + const [teams, setTeams] = useState<{ id: string; name: string }[]>([]); + + const providerModels = providerOptions.find((option) => option.id === provider)?.models ?? []; + const isEditing = selectedAgentId !== null; + + const loadAgents = async () => { + setLoading(true); + setError(null); + + const { data, error: selectError } = await supabase + .from('agents') + .select('id,name,role,api_provider,model,system_prompt,created_at') + .order('created_at', { ascending: false }); + + if (selectError) setError(selectError.message); + setAgents((data ?? []) as Agent[]); + setLoading(false); + }; + + useEffect(() => { + loadAgents(); + fetchTeams(); + }, []); + + const fetchTeams = async () => { + try { + const { data } = await supabase.from('teams').select('id, name'); + setTeams(data || []); + } catch (err) { + console.error('Failed to fetch teams'); + } + }; + + const handleProviderChange = (value: SupportedProvider) => { + setProvider(value); + setModel(getDefaultModel(value)); + }; + + const resetForm = () => { + setSelectedAgentId(null); + setName(''); + setRole(''); + setProvider(defaultProvider); + setModel(getDefaultModel(defaultProvider)); + setSystemPrompt(''); + }; + + const selectAgent = (agent: Agent) => { + setSelectedAgentId(agent.id); + setName(agent.name); + setRole(agent.role ?? ''); + setProvider(agent.api_provider); + setModel(agent.model); + setSystemPrompt(agent.system_prompt ?? ''); + setMessage(null); + setError(null); + }; + + const saveAgent = async (event: React.FormEvent) => { + event.preventDefault(); + if (!user) { + setError(`You must be signed in to ${isEditing ? 'update' : 'create'} an agent.`); + return; + } + + setSaving(true); + setError(null); + setMessage(null); + + const payload = { + user_id: user.id, + name, + role, + api_provider: provider, + model, + system_prompt: systemPrompt || `You are ${name}, acting as ${role || 'an AI agent'}.` + }; + + const response = isEditing + ? await supabase.from('agents').update(payload).eq('id', selectedAgentId) + : await supabase.from('agents').insert(payload); + + if (response.error) { + setError(response.error.message); + } else { + resetForm(); + setMessage(isEditing ? 'Agent updated successfully.' : 'Agent created successfully.'); + await loadAgents(); + } + + setSaving(false); + }; + + const handleShareTemplate = async () => { + if (!sharingAgent || !user) return; + setSaving(true); + try { + const { error } = await supabase.from('agent_templates').insert({ + name: sharingAgent.name, + role: sharingAgent.role, + description: shareDescription || `Custom agent: ${sharingAgent.name}`, + model: sharingAgent.model, + api_provider: sharingAgent.api_provider, + system_prompt: sharingAgent.system_prompt, + category: shareCategory, + author_id: user.id, + team_id: isPublicTemplate ? null : shareToTeam, + is_public: isPublicTemplate + }); + + if (error) throw error; + setMessage('Agent shared to marketplace!'); + setShowShareModal(false); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+ +
+

Agents

+

Create custom agents and choose the LLM provider used at runtime.

+
+
+ +
+ + {error &&
{error}
} + {message &&
{message}
} + +
+
+
+
+ +

{isEditing ? 'Edit Agent' : 'Create Agent'}

+
+ {isEditing && ( + + )} +
+
+ + +
+ + +
+