diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e13d81..c9c5b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,11 @@ jobs: pip install pytest pytest-timeout matplotlib - name: Run backend tests + # DATABASE_PATH is a belt-and-suspenders backstop: tests/conftest.py + # already self-isolates into a tempdir, but if that fixture ever + # regresses, this keeps CI from writing into the committed seed DB. + env: + DATABASE_PATH: ${{ runner.temp }}/ci-backtest.db run: pytest dashboard/backend/tests/ --timeout=180 -p no:cacheprovider packaging-tests: diff --git a/.gitignore b/.gitignore index 828b125..a9691fd 100644 --- a/.gitignore +++ b/.gitignore @@ -242,4 +242,12 @@ FinAgents/agent_pools/backtest_agent/qlib_data/ # Local Alpaca credentials (use credentials/alpaca.json.example as template) credentials/* !credentials/alpaca.json.example -!credentials/README.md \ No newline at end of file +!credentials/README.md + +# claude-mem auto-generates per-folder recall stubs named CLAUDE.md in every +# directory it visits (empty placeholders that fill in over +# time). The feature stays ON — but these are machine-managed noise in git, so +# ignore every *nested* CLAUDE.md while keeping the real, team-shared root file. +# To commit a genuine nested CLAUDE.md on purpose: git add -f path/to/CLAUDE.md +**/CLAUDE.md +!/CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ff6828d..b580d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> **Packaging contract (this branch).** This branch restructured the backend from -> flat top-level modules into the **`dashboard.backend` Python package**. This -> `CLAUDE.md` documents that packaged contract. It intentionally **supersedes** the -> older flat-imports `CLAUDE.md` on `main` — when this merges, take **this** version -> (that older guidance describes code that no longer exists here). See +> **Packaging contract.** The backend is the **`dashboard.backend` Python package** +> (restructured from flat top-level modules in PR #67, hardened by PR #71 — both long +> since merged; this packaged layout is the only one that exists now). Import by full +> package path; run via the app's import string, never by file path. See > `docs/architecture/dashboard-target-structure.md` for the full layout. ## What this repo is @@ -49,7 +48,7 @@ pytest packaging/agentictrading/tests/ -v python dashboard/scripts/backtest_hourly_agent.py # main hourly agent backtest ``` -`tests/conftest.py` points `DATABASE_PATH` at a temp file before any backend import, so tests never touch the committed `dashboard/storage/data/backtest.db`. +`dashboard/backend/tests/conftest.py` points `DATABASE_PATH` at a temp file before any backend import, so tests never touch the committed `dashboard/storage/data/backtest.db`. The suite is green end-to-end (the old "5 pre-existing failures" were retired in PR #71) — a red test on a fresh run is a real regression. ## Environment & credentials @@ -61,18 +60,21 @@ python dashboard/scripts/backtest_hourly_agent.py # main hourly agent backte Pipeline is **backtest → SQLite → API → dashboard**. The backend is layered (see `docs/architecture/dashboard-target-structure.md`): -- **`api/`** — FastAPI surface. Business routers live in `api/routers/*` and are mounted by `api/router.py` under `/api`. **Paper-trading routes stay outside `/api`** (registered directly on the app), so `/paper/*` is the external contract. `app.py` is the composition root (creates the app, middleware, startup hooks, serves the frontend). -- **`domain/`** — business logic by area: `runs/` (Agent-Environment Protocol: Run/Step/Decision), `agents/`, `leaderboard/` (contest + baseline strategies registry), `backtesting/` (engine, `external_run_service`, portfolio manager), `strategies/` (free-form strategy store), `chat/`, `trading/`. Domain must not import `api/`/`app.py`. -- **`infrastructure/`** — `llm/` (the `validator` security boundary, `token_cost`, `backtest_harness`/gateway client) and `market_data/` (Alpaca bars). -- **`services/` / re-export shims** — several moved modules keep thin compatibility re-exports. -- **Persistence** (`database.py` + per-store repositories like `domain/runs/repository.py`, `domain/strategies/repository.py`): thin SQLite wrappers over `DATABASE_PATH`; schema is created lazily and self-migrates. -- **Frontend** (`dashboard/frontend/`): vanilla JS + Chart.js, no build step. Served by the backend locally/Render; also deployed static to Vercel. +- **`api/`** — FastAPI surface. Business routers live in `api/routers/*` and are mounted by `api/router.py` under `/api`; the canonical agent contract is `api/v2/*` (see "Agent API v2" below). **Paper-trading routes stay outside `/api`** (registered directly on the app), so `/paper/*` is the external contract. `app.py` is the composition root (creates the app, middleware, startup hooks, serves both frontends). +- **`domain/`** — business logic by area: `runs/` (Agent-Environment Protocol: Run/Step/Decision), `agents/`, `leaderboard/` (contest + baseline strategies registry + the H6 integrity guard), `backtesting/` (engine, `external_run_service`, portfolio manager, `baselines/` subpackage), `strategies/` (free-form strategy store), `chat/`, `trading/` (live paper trading: `paper_session`, `execution`, `portfolio`). Domain must not import `api/`/`app.py`. +- **`execution/`** — v2 execution backends binding domain engines to the `/api/v2` contract: `base.py` (interface), `backtest_backend.py` (implemented), `paper_backend.py` (**stub** — raises `NotImplementedError`; Phase B not built). Deliberately at the backend root (not `domain/`) so it can bridge domain→API without tripping the `domain/`→`api/` import ban. +- **`infrastructure/`** — `llm/` (the `validator` security boundary, `token_cost`, `backtest_harness`/gateway client), `market_data/` (Alpaca bars), and `brokers/` (`alpaca_paper.py`, the isolated Alpaca paper-trading HTTP adapter). +- **Backend-root modules** — `middleware.py` (session enforcement + CSP), `users.py` (auth/bcrypt/session store), `cache.py` (TTL cache for paper-trading responses), `baseline_generator.py`/`baseline_resolver.py`/`baselines_endpoint.py` (shared baseline equity-curve generation + DJIA/buy-hold baselines for backtests and paper trading), `llm_integration_example.py` (reference safe-LLM pattern). (`engines/` and `services/` are **not** packages — they were pre-refactor compatibility shims, deleted once their code moved under `domain/`; `test_architecture_boundaries.py`'s `_DELETED_SHIMS` list asserts they stay non-importable.) +- **Persistence** (`database.py` + per-store repositories like `domain/runs/repository.py`, `domain/strategies/repository.py`): thin SQLite wrappers over `DATABASE_PATH` in **WAL journal mode** (readers aren't blocked by finalize's heavy writes); schema is created lazily and self-migrates. `agent_runs` carries a JSON `metadata` column recording the effective `LLM_MAX_OUTPUT_TOKENS` per run. +- **Frontend** — `dashboard/frontend/` is the served static root and holds **both** UIs: the **landing page** (`index.html` + `assets/`) served at **`/`**, and the vanilla-JS + Chart.js **dashboard** (`app.html`, `app.js`, `styles.css`, no build step) served at **`/app`**. The landing page is a Vite/React marketing site whose **source** lives in `dashboard/landing/` (Replit-exported, de-monorepo'd; `npm run build`); its build output ships as `frontend/index.html` + `frontend/assets/`. `app.py` adds a `/app/`→`/app` 308 redirect so the dashboard's relative asset paths resolve. Vercel deploys the static `dashboard/frontend`. - **Paths** (`dashboard/backend/paths.py`): single source of truth for on-disk locations. ### Baseline strategies (registry pattern) `dashboard/backend/domain/leaderboard/strategies/` holds benchmark strategies (`buy_hold`, `equal_weight_index`, `market_index`, `mean_variance`, `llm_agent`, …). To add one: subclass `BaselineStrategy` (`base.py`), give it a `key`, add the class to `_STRATEGY_CLASSES` in `registry.py`. `get_strategy(config)` resolves by `strategy`/`type` key. +**H6 leaderboard integrity guard.** An LLM-backed entry can only publish if the model actually drove ≥95% of its steps (`MIN_LLM_DECISION_COVERAGE = 0.95`). The guard (`domain/leaderboard/service.py`) keys on `PortfolioManager.llm_decisions` — steps the model genuinely drove, incremented only at the *success exit* of the decision path — **not** `llm_calls` (a pure billing counter that also ticks on truncated/unparseable responses that then silently fall back to rule-based). This stops a rule-based fallback curve from being published under an LLM's name. See the memory note `leaderboard-h6-integrity-model` for the full rationale. All 6 LLM entries currently on the board (Claude Haiku 4.5, Sonnet 4.6, GPT-5.5, Gemini 3.1 Pro, Qwen3.7 Plus, DeepSeek V4 Pro) cleared it; only DeepSeek beat the passive baselines. + ### LLM safety boundary `infrastructure/llm/validator.py` is a hard security boundary: LLM trading responses must be JSON-only matching the trading schema — `tool_calls`/`function_calls` are rejected, portfolio constraints enforced, decisions logged. Do not loosen this to allow tool/web access from agent responses. @@ -87,8 +89,9 @@ Pipeline is **backtest → SQLite → API → dashboard**. The backend is layere Two step-driven agent surfaces coexist; they are **not peers**: -- **`/api/v2` is canonical** (`api/v2/*` routers + `execution/` backends over the same domain engines): typed Pydantic contract, per-agent scopes + token-bucket rate limits, canonical `run_id`, DB-backed idempotency (`(run_id, idem_key)`), `context_ref` provenance, self-describing `GET /api/v2/schema`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-23-agent-api-foundation-*`. New agent-facing features land here (Phase B: paper/live via `ExecutionBackend`; Phase C: MCP façade). +- **`/api/v2` is canonical** (`api/v2/*` routers + `execution/` backends over the same domain engines): typed Pydantic contract, per-agent scopes + token-bucket rate limits, canonical `run_id`, DB-backed idempotency (`(run_id, idem_key)`), `context_ref` provenance, self-describing `GET /api/v2/schema`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-23-agent-api-foundation-*`. New agent-facing features land here. **Phase B** (paper/live via `ExecutionBackend`) and **Phase C** (MCP façade) are **not built yet** — `execution/paper_backend.py` is a stub. - **`/api/v1` is the compatibility surface** for the shipping SDK (`packaging/agentictrading`), Discord bot, and built-in agents. Keep it working; do not grow it. Migrating the SDK to v2 is the gate for publishing `agentictrading` 0.2.0. +- **Unified run lifecycle (v1 + v2).** The two surfaces share one active-run cap ledger (under a single lock), one reaper sweep (`register_reaper_sweep()` reaches v2 runs), and multi-worker heartbeat recovery (`owner_instance`/`heartbeat_at` columns, `RUN_HEARTBEAT_STALE_SECONDS`). Terminal v2 runs are swapped for a DB-backed `ArchivedBacktestBackend` tombstone; step/idempotency state persists across process restarts; v2 `cancel`/`status` report the true terminal status (not always "closed"). - `execution/` sits at the backend root (not `domain/`) deliberately: the backends bind domain engines to the v2 API contract, and `test_architecture_boundaries` forbids `domain/` → `api/` imports. ## Deployment @@ -104,3 +107,5 @@ Two step-driven agent surfaces coexist; they are **not peers**: - The committed `dashboard/storage/data/backtest.db` holds seed runs referenced by `dashboard/config/defaults.json`. Importing a store module runs `CREATE TABLE IF NOT EXISTS` against `DATABASE_PATH`, so running the app locally can add empty tables to that file — don't commit those mutations. If you regenerate the DB, update `defaults.json`. - Pytest is not in `requirements.txt`; install it separately. - `discord.py` (for `integrations/discord_bot.py`) is an **optional** dep declared in `requirements-discord.txt` (like `requirements-sphinx.txt` for the docs build), not core `requirements.txt` — run `pip install -r requirements-discord.txt` to run the bot. It's kept out of core so web/API/backtest installs stay lean; its tests `importorskip('discord')`. +- **Prod deploy reality vs `render.yaml`.** The live Render service runs on the **free tier** and tracks the **Allan-Feng/AgenticTrading** fork's `main`, not this repo's `render.yaml` — there is **no persistent `/data` disk**, so the running DB is the ephemeral committed seed `backtest.db` (writes evaporate on redeploy). Merging to Open-Finance-Lab `main` therefore doesn't update prod until a maintainer syncs OFL→allan-fork. The disk/plan in `render.yaml` is aspirational. +- **Phantom `test_deleted_shim_is_not_importable` failures = stale bytecode, not a regression.** If those cases fail locally with `DID NOT RAISE ModuleNotFoundError`, it's leftover `dashboard/backend/{engines,services}/__pycache__/*.pyc` from the pre-refactor layout, which Python resolves as a PEP-420 namespace package. The dirs are untracked so CI is green; `rm -rf dashboard/backend/engines dashboard/backend/services` clears it. diff --git a/dashboard/backend/api/dependencies.py b/dashboard/backend/api/dependencies.py index e4f5a78..3143e3f 100644 --- a/dashboard/backend/api/dependencies.py +++ b/dashboard/backend/api/dependencies.py @@ -35,6 +35,12 @@ def _owner_context(request: Request, authorization: Optional[str]) -> Dict[str, trading_session = request.headers.get("x-session-id") or request.headers.get("X-Session-Id") browser_owner = request.headers.get("x-browser-id") or request.headers.get("X-Browser-Id") if not browser_owner: + # Fallback: with no X-Browser-Id, the X-Session-Id doubles as the owner + # identity. For agents created via import_session (which stores + # owner_browser_session = session_id) the session id therefore IS an + # ownership credential — "session_id is never a credential" only holds + # for built-in agents. Clients that can send X-Browser-Id should; this + # branch exists for API-only importers with no browser identity. browser_owner = trading_session user = _optional_user(authorization) return { diff --git a/dashboard/backend/api/v2/runs.py b/dashboard/backend/api/v2/runs.py index 5f8fc5f..c7cdd28 100644 --- a/dashboard/backend/api/v2/runs.py +++ b/dashboard/backend/api/v2/runs.py @@ -21,8 +21,26 @@ ) from dashboard.backend.api.v2.auth_scopes import require_scope from dashboard.backend.database import db -from dashboard.backend.domain.runs.service import MAX_ACTIVE_RUNS_PER_AGENT -from dashboard.backend.execution.backtest_backend import BacktestBackend +# The v1 service's create lock is shared on purpose: both surfaces count +# active runs from the same protocol_runs ledger, so both check-then-insert +# sequences must serialize on ONE lock or an agent could race one create per +# surface past the combined cap. Known trade-off: run creation across BOTH +# surfaces serializes on this lock, including its quick SQLite writes — +# acceptable because creates are rare and bounded (cap per agent), and +# correctness of the cap beats create throughput. Long work (market-data +# load) stays off the lock (background thread). +from dashboard.backend.domain.runs.service import ( + MAX_ACTIVE_RUNS_PER_AGENT, + _create_lock as _shared_create_lock, +) +# Late-bound module reference (run_repo.run_store) so tests that swap the +# run_store singleton cover this module too. +from dashboard.backend.domain.runs import repository as run_repo +from dashboard.backend.execution.backtest_backend import ( + ArchivedBacktestBackend, + BacktestBackend, +) +from dashboard.backend.execution.base import TERMINAL_STATUSES as _TERMINAL_STATUSES from dashboard.backend.api.v2.rate_limit import enforce router = APIRouter(prefix="/v2/runs", tags=["v2-runs"]) @@ -30,15 +48,19 @@ # run_id -> {"backend": ExecutionBackend, "session_id": str, "agent_id": str|None} _runs: Dict[str, Dict[str, Any]] = {} _lock = threading.Lock() -# Serializes the cap check with run creation (check-then-act TOCTOU) — same -# pattern as the v1 protocol's _create_lock. -_create_lock = threading.Lock() # MAX_ACTIVE_RUNS_PER_AGENT (imported from the v1 run service — one knob for # both surfaces): each active run pins an in-memory engine session holding -# market data. NOTE: the cap is per SURFACE — v1 and v2 keep separate -# registries, so an agent can hold up to 2× the limit across both; unifying -# the registries is part of the run-lifecycle persistence work. +# market data. The cap is enforced across BOTH surfaces: every v2 run writes a +# protocol_runs row through its lifecycle, and both create paths count +# run_store.count_active_runs() under the shared v1 create lock. +# +# Documented consequence of the shared ledger: an agent holding both surfaces' +# credentials can see its v2 runs listed by /api/v1/runs (status-level record +# only; v1 step/decision queries have nothing to serve for them) and read its +# own v1 runs' terminal rows through v2 rehydration. This is intentional — +# run ids are canonical and both surfaces are the same agent identity; v2 is +# the canonical surface, v1 the compat view. def _mint_run_id() -> str: @@ -54,25 +76,135 @@ def register_run(run_id: str, backend: Any, session_id: str, "agent_id": agent_id} -def _active_run_count(agent_id: str) -> int: +def _terminal_status(backend: Any) -> str: + """Resolve a no-longer-active backend's terminal state. + + Prefers the raw session attribute (a plain read, no locks). status() is + only consulted when there is no session — and only ever on an INACTIVE + backend, where it cannot cascade into deadline handling or _finalize(). + """ + status = getattr(getattr(backend, "session", None), "status", None) + if status is None: + try: + status = (backend.status() or {}).get("status") + except Exception: + status = None + return status if status in ("completed", "closed") else "failed" + + +def _archive_run(run_id: str, entry: Dict[str, Any], backend: Any) -> None: + """Fold a finished backend into its protocol_runs row and swap in a + DB-backed tombstone (frees the engine session's market-data buffers).""" + status = _terminal_status(backend) + session = getattr(backend, "session", None) + result_run_id = getattr(session, "run_id", None) or run_id + + def _progress(attr: str) -> Optional[int]: + # Prefer the live session's count, else the backend's own (a tombstone + # carries them directly). `is None`, not `or`, so a real 0 is kept. + val = getattr(session, attr, None) if session is not None else None + return val if val is not None else getattr(backend, attr, None) + + step_index = _progress("step_index") + total_steps = _progress("total_steps") + try: + run_repo.run_store.update_run( + run_id, + status=status, + result_run_id=result_run_id if status == "completed" else None, + # Persist step counts so a post-restart from_record reports real + # progress for a failed/closed run instead of a misleading 0/0. + step_index=step_index, + total_steps=total_steps, + ) + except Exception as exc: + # Do NOT swap in the tombstone: with the backend gone nothing would + # ever retry this write and the row would freeze in an active status + # (mis-holding a cap slot until stale recovery mislabels it failed). + # Keep the backend live so the next sweep retries. + print(f"⚠️ v2 archive: row update failed for {run_id}, retrying next sweep: {exc}") + return + archived = ArchivedBacktestBackend( + run_id=run_id, + session_id=entry["session_id"], + status=status, + error=getattr(session, "error", None), + step_index=step_index, + total_steps=total_steps, + result_run_id=result_run_id, + ) + with _lock: + live = _runs.get(run_id) + # An in-flight request may still hold the old backend object; it stays + # alive via that reference and its session is thread-safe, so swapping + # under a racing read is benign. + if live is not None and live["backend"] is backend: + live["backend"] = archived + + +def _reconcile_terminal_backends(agent_id: Optional[str] = None) -> None: + """Fold any backend that finished since the last sweep into its row. + + Runs under the shared create lock, so it must stay passive on LIVE runs: + only the is_active() peek is consulted; status()/advance() are never + called on an active backend here.""" with _lock: - entries = [e for e in _runs.values() if e.get("agent_id") == agent_id] - active = 0 - for entry in entries: - # is_active() is the passive liveness peek — never status(), which on - # a live session can cascade into deadline handling and _finalize() - # (seconds of baseline work) while create_run holds the global lock. + items = [ + (rid, e) for rid, e in _runs.items() + if agent_id is None or e.get("agent_id") == agent_id + ] + for run_id, entry in items: + backend = entry["backend"] + if isinstance(backend, ArchivedBacktestBackend): + continue try: - if entry["backend"].is_active(): - active += 1 + if backend.is_active(): + continue except Exception: - active += 1 # unknown state counts against the cap (fail closed) - return active + continue # unknown state keeps its row (and cap slot) — fail closed + _archive_run(run_id, entry, backend) + + +def _active_run_count(agent_id: str) -> int: + """Active runs across BOTH surfaces — the protocol_runs ledger is shared + with /api/v1. Reconcile first so a v2 run that finished since the last + reaper sweep does not hold a phantom cap slot.""" + _reconcile_terminal_backends(agent_id) + return run_repo.run_store.count_active_runs(agent_id) + + +def _rehydrate_terminal_run(run_id: str) -> Optional[Dict[str, Any]]: + """Rebuild a registry entry from a terminal protocol_runs row. + + After a restart the in-memory registry is empty but the run's terminal + state (startup recovery marks orphans failed), result linkage, decisions + and idempotency acks are all DB-backed — owners keep read/replay access + instead of a 404. Non-terminal rows are NOT rehydrated: with no live + backend here they belong to another worker or are awaiting recovery.""" + try: + record = run_repo.run_store.get_run(run_id) + except Exception: + return None + if not record or record.get("status") not in _TERMINAL_STATUSES: + return None + entry = { + "backend": ArchivedBacktestBackend.from_record(record), + "session_id": record["session_id"], + "agent_id": record.get("agent_id"), + } + with _lock: + existing = _runs.get(run_id) + if existing is not None: + return existing + _runs[run_id] = entry + return entry def _require_run(run_id: str, session_id: str) -> Any: with _lock: entry = _runs.get(run_id) + if entry is None: + entry = _rehydrate_terminal_run(run_id) # A run owned by another session answers exactly like a missing one — a # message-text difference would let any key holder enumerate run ids. if not entry or entry["session_id"] != session_id: @@ -80,6 +212,55 @@ def _require_run(run_id: str, session_id: str) -> Any: return entry["backend"] +def reap_v2_runs() -> int: + """Per-pass v2 sweep, registered with the v1 reaper (composition root). + + Drives abandoned runs through elapsed decision deadlines (advance()), + heartbeats rows whose backend is live in this process, and archives + terminal backends (row update + tombstone swap). Returns the number of + backends archived this pass.""" + with _lock: + items = list(_runs.items()) + live_ids = [] + prunable = [] # tombstones archived in a PRIOR pass — safe to evict now + reaped = 0 + for run_id, entry in items: + backend = entry["backend"] + if isinstance(backend, ArchivedBacktestBackend): + prunable.append((run_id, backend)) + continue + try: + backend.advance() # applies any pending deadline auto-hold + except Exception as exc: # a wedged run must not stall the sweep + print(f"⚠️ v2 reap: drain failed for {run_id}: {exc}") + try: + active = backend.is_active() + except Exception: + active = True # unknown state: keep it registered, try next pass + if active: + live_ids.append(run_id) + else: + _archive_run(run_id, entry, backend) + reaped += 1 + # Evict prior-pass tombstones so _runs is bounded by ACTIVE runs, not by + # total historical runs. The terminal row is DB-backed, so a later read + # rebuilds an equivalent tombstone via _rehydrate_terminal_run. Runs + # archived THIS pass stay one interval, so an immediately-following read is + # still served in-process (and single-pass tests see the tombstone). + if prunable: + with _lock: + for run_id, backend in prunable: + cur = _runs.get(run_id) + if cur is not None and cur["backend"] is backend: + _runs.pop(run_id, None) + if live_ids: + try: + run_repo.run_store.heartbeat_runs(live_ids) + except Exception as exc: + print(f"⚠️ v2 reap: heartbeat pass failed: {exc}") + return reaped + + # -- pure helpers (unit-testable without HTTP) ----------------------------- def _context_for(run_id: str, session_id: str) -> Dict[str, Any]: @@ -162,7 +343,7 @@ def create_run(body: CreateRunBody, response: Response, DB and the engine session synchronously (B0/H4 convention). """ enforce(agent["agent_id"], response) - with _create_lock: + with _shared_create_lock: active = _active_run_count(agent["agent_id"]) if active >= MAX_ACTIVE_RUNS_PER_AGENT: raise ApiError( @@ -186,8 +367,33 @@ def create_run(body: CreateRunBody, response: Response, schema_version=SCHEMA_VERSION, news_sentiment_source=backend.news_sentiment_source, ) db.insert_run_manifest(run_id, manifest.model_dump()) - backend.start_background_load() - register_run(run_id, backend, agent["session_id"], agent["agent_id"]) + # The run's protocol_runs row: the ledger shared with /api/v1 that the + # cap counts, startup recovery fails, and post-restart reads rehydrate. + # Inserted under the create lock so a concurrent create sees it. + run_repo.run_store.create_run( + run_id=run_id, + agent_id=agent["agent_id"], + agent_version_id=None, + session_id=agent["session_id"], + environment_id=None, + environment_type="backtest", + config={ + "start_date": body.start_date, "end_date": body.end_date, + "mode": body.strategy_mode, "universe": body.universe, + }, + backtest_id=None, + status="loading", + ) + try: + backend.start_background_load() + register_run(run_id, backend, agent["session_id"], agent["agent_id"]) + except Exception: + # Never leak an active-looking row for a run that never started. + try: + run_repo.run_store.update_run(run_id, status="failed") + except Exception: + pass + raise return { "run_id": run_id, "mode": "backtest", "status": "loading", "loop": backend.loop, "decision_timeout_seconds": ext.DECISION_TIMEOUT_SECONDS, @@ -235,6 +441,26 @@ def decisions_log(run_id: str, agent: dict = Depends(require_scope("runs:read")) @router.post("/{run_id}/cancel") def cancel_run(run_id: str, agent: dict = Depends(require_scope("runs:write"))): + """Cancel a live run; on an already-terminal run this is a no-op that + reports the run's TRUE state. Persisting a hardcoded "closed" here used + to clobber a completed run's ledger row (and hide its metrics) when a + cleanup cancel raced the final decision.""" backend = _require_run(run_id, agent["session_id"]) - backend.cancel() - return {"run_id": run_id, "status": "closed"} + try: + active = backend.is_active() + except Exception: + active = False + if active: + backend.cancel() # backend-level guard never clobbers terminal state + status = _terminal_status(backend) + if active: + # active is read from backend.is_active(), which an ArchivedBacktestBackend + # (tombstone) always reports False — so active already implies a live + # backend. Persist only a transition this request actually caused: if the + # run finalized between the peek and cancel(), _terminal_status already + # reads "completed" and that is what lands — never a downgrade. + try: + run_repo.run_store.update_run(run_id, status=status) + except Exception: + pass # best-effort; the sweep reconciles + return {"run_id": run_id, "status": status} diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index a28f1c5..05b2ad5 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -175,6 +175,17 @@ def init_paper_baselines(): except Exception as e: print(f"⚠️ Orphaned-run recovery error: {e}") + try: + # Composition-root wiring (the domain reaper must not import api/*): + # each reaper pass also sweeps the v2 registry — drains abandoned v2 + # runs, heartbeats live ones, archives terminal backends. + from dashboard.backend.api.v2.runs import reap_v2_runs + from dashboard.backend.domain.runs.service import register_reaper_sweep + register_reaper_sweep(reap_v2_runs) + print("🧹 v2 run sweep registered with the reaper") + except Exception as e: + print(f"⚠️ v2 sweep registration error: {e}") + try: from dashboard.backend.domain.runs.service import start_reaper start_reaper() diff --git a/dashboard/backend/baseline_generator.py b/dashboard/backend/baseline_generator.py index d3543b2..410c69a 100644 --- a/dashboard/backend/baseline_generator.py +++ b/dashboard/backend/baseline_generator.py @@ -18,10 +18,15 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple from datetime import datetime, timedelta -import sys from dashboard.backend.paths import CREDENTIALS_DIR -from dashboard.backend.domain.leaderboard.strategies._common import timestamps_in_contest +from dashboard.backend.infrastructure.market_data.alpaca_bars import ( + MarketDataUnavailableError, +) + +# NOTE: domain.leaderboard.strategies._common is imported lazily inside the +# methods that need it — the strategies package imports this module back +# (buy_hold et al.), so a top-level import here is a circular import. # Try to import numpy try: @@ -79,7 +84,13 @@ def _load_credentials(self): except Exception as e: print(f"❌ Failed to load credentials from file: {e}") print(" Set ALPACA_API_KEY and ALPACA_SECRET_KEY environment variables") - sys.exit(1) + # A plain exception, not sys.exit(1): baselines are generated inside + # the server (paper init, leaderboard strategies, backtest finalize) + # where SystemExit would evade `except Exception` (the B0 class). + raise MarketDataUnavailableError( + "Alpaca credentials not found (set ALPACA_API_KEY and " + "ALPACA_SECRET_KEY, or provide credentials/alpaca.json)" + ) from e def _fetch_bars_for_symbol(self, symbol: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]: """ @@ -97,9 +108,11 @@ def _fetch_bars_for_symbol(self, symbol: str, start_date: str, end_date: str) -> from alpaca.data.historical import StockHistoricalDataClient from alpaca.data.requests import StockBarsRequest from alpaca.data.timeframe import TimeFrame - except ImportError: + except ImportError as e: print("❌ alpaca-py not installed. Install with: pip install alpaca-py") - sys.exit(1) + raise MarketDataUnavailableError( + "alpaca-py is not installed (pip install alpaca-py)" + ) from e try: client = StockHistoricalDataClient(self.api_key, self.secret_key) @@ -186,6 +199,9 @@ def generate_buyhold_baseline( market_hours_only.append(ts) all_timestamps = market_hours_only + from dashboard.backend.domain.leaderboard.strategies._common import ( + timestamps_in_contest, + ) all_timestamps = timestamps_in_contest(all_timestamps, start_date, end_date) if not all_timestamps: @@ -316,6 +332,9 @@ def generate_index_baseline( market_hours_only.append(ts) all_timestamps = market_hours_only + from dashboard.backend.domain.leaderboard.strategies._common import ( + timestamps_in_contest, + ) all_timestamps = timestamps_in_contest(all_timestamps, start_date, end_date) if not all_timestamps: diff --git a/dashboard/backend/database.py b/dashboard/backend/database.py index 0273982..d4478c6 100644 --- a/dashboard/backend/database.py +++ b/dashboard/backend/database.py @@ -19,17 +19,40 @@ DB_PATH = Path(os.getenv("DATABASE_PATH", str(DEFAULT_DB_PATH))) +def enable_wal(db_path) -> None: + """Switch a SQLite file to WAL journal mode (best-effort, idempotent). + + Both DB layers (BacktestDatabase and the protocol RunStore) share one + file; WAL lets request-thread reads proceed while a backtest finalize + commits its heavy equity/trade writes. journal_mode is persisted in the + file, so one switch covers every later connection from either layer. + Filesystems without shared-memory support (some network mounts) can + refuse WAL — keep the default rollback journal there rather than failing + startup. Single definition so a future refinement can't drift between the + two call sites. + """ + try: + conn = sqlite3.connect(str(db_path)) + try: + conn.execute("PRAGMA journal_mode=WAL") + finally: + conn.close() + except sqlite3.Error: + pass + + class BacktestDatabase: """Minimal SQLite wrapper for equity curve storage.""" - + def __init__(self, db_path: Path = None): if db_path is None: db_path = DB_PATH self.db_path = Path(db_path) self.db_path.parent.mkdir(parents=True, exist_ok=True) + enable_wal(self.db_path) self._init_schema() self._migrate_schema() # Handle existing DBs - + def _get_connection(self): """Get database connection.""" conn = sqlite3.connect(str(self.db_path)) @@ -60,6 +83,7 @@ def _init_schema(self): input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, est_cost_usd REAL DEFAULT 0, + metadata TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) @@ -229,12 +253,15 @@ def _migrate_schema(self): conn.commit() print("✅ Added baseline_buyhold_run_id to agent_runs") - # Token usage / cost tracking columns + # Token usage / cost tracking columns + the JSON config snapshot + # (metadata records env-dependent knobs like the effective + # LLM_MAX_OUTPUT_TOKENS that shaped the run). token_columns = [ ("llm_calls", "INTEGER DEFAULT 0"), ("input_tokens", "INTEGER DEFAULT 0"), ("output_tokens", "INTEGER DEFAULT 0"), ("est_cost_usd", "REAL DEFAULT 0"), + ("metadata", "TEXT"), ] for col_name, col_def in token_columns: if col_name not in columns: @@ -361,23 +388,28 @@ def insert_run(self, run_id: str, session_id: str, agent_name: str, mode: str, llm_calls: int = 0, input_tokens: int = 0, output_tokens: int = 0, - est_cost_usd: float = 0.0) -> None: - """Insert a new backtest run with session_id, LLM model and token-cost tracking.""" + est_cost_usd: float = 0.0, + metadata: Optional[Dict[str, Any]] = None) -> None: + """Insert a new backtest run with session_id, LLM model and token-cost tracking. + + ``metadata`` is an optional JSON config snapshot (e.g. the effective + LLM_MAX_OUTPUT_TOKENS in force during the run).""" conn = self._get_connection() cursor = conn.cursor() - + cursor.execute(""" - INSERT OR REPLACE INTO agent_runs - (run_id, session_id, agent_name, mode, start_date, end_date, - initial_equity, final_equity, total_return, sharpe_ratio, + INSERT OR REPLACE INTO agent_runs + (run_id, session_id, agent_name, mode, start_date, end_date, + initial_equity, final_equity, total_return, sharpe_ratio, max_drawdown, num_trades, llm_model, - llm_calls, input_tokens, output_tokens, est_cost_usd) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + llm_calls, input_tokens, output_tokens, est_cost_usd, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (run_id, session_id, agent_name, mode, start_date, end_date, initial_equity, final_equity, total_return, sharpe_ratio, max_drawdown, num_trades, llm_model, - llm_calls, input_tokens, output_tokens, est_cost_usd)) - + llm_calls, input_tokens, output_tokens, est_cost_usd, + json.dumps(metadata) if metadata is not None else None)) + conn.commit() conn.close() @@ -447,6 +479,18 @@ def insert_equity_points(self, run_id: str, conn.commit() conn.close() + @staticmethod + def _parse_run_row(run: Dict) -> Dict: + """Decode the JSON metadata column (SELECT * returns raw text) so + every agent_runs reader hands out the same parsed shape.""" + raw = run.get("metadata") + if raw is not None: + try: + run["metadata"] = json.loads(raw) + except (TypeError, ValueError): + run["metadata"] = None + return run + def get_all_runs(self) -> List[Dict]: """Get metadata for all runs.""" conn = self._get_connection() @@ -456,7 +500,7 @@ def get_all_runs(self) -> List[Dict]: rows = cursor.fetchall() conn.close() - return [dict(row) for row in rows] + return [self._parse_run_row(dict(row)) for row in rows] def get_runs_by_session(self, session_id: str) -> List[Dict]: """Get all runs for a specific session.""" @@ -472,7 +516,7 @@ def get_runs_by_session(self, session_id: str) -> List[Dict]: rows = cursor.fetchall() conn.close() - return [dict(row) for row in rows] + return [self._parse_run_row(dict(row)) for row in rows] def get_runs_by_sessions(self, session_ids: List[str]) -> Dict[str, List[Dict]]: """Get all runs for several sessions in one query, grouped by session. @@ -499,7 +543,7 @@ def get_runs_by_sessions(self, session_ids: List[str]) -> Dict[str, List[Dict]]: conn.close() for row in rows: - run = dict(row) + run = self._parse_run_row(dict(row)) grouped[run["session_id"]].append(run) return grouped @@ -512,7 +556,7 @@ def get_run(self, run_id: str) -> Optional[Dict]: row = cursor.fetchone() conn.close() - return dict(row) if row else None + return self._parse_run_row(dict(row)) if row else None def get_run_with_session(self, run_id: str, session_id: str) -> Optional[Dict]: """Get a run, verifying it belongs to the session.""" @@ -526,7 +570,7 @@ def get_run_with_session(self, run_id: str, session_id: str) -> Optional[Dict]: row = cursor.fetchone() conn.close() - return dict(row) if row else None + return self._parse_run_row(dict(row)) if row else None def get_equity_curve(self, run_id: str) -> List[Dict]: """Get full equity curve for a run.""" @@ -558,15 +602,15 @@ def get_runs_by_mode(self, mode: str) -> List[Dict]: cursor = conn.cursor() cursor.execute(""" - SELECT * FROM agent_runs + SELECT * FROM agent_runs WHERE mode = ? ORDER BY created_at DESC """, (mode,)) - + rows = cursor.fetchall() conn.close() - - return [dict(row) for row in rows] + + return [self._parse_run_row(dict(row)) for row in rows] def insert_trades(self, run_id: str, trades: List[Dict[str, Any]]) -> None: """Batch insert trade records for a backtest run.""" diff --git a/dashboard/backend/domain/backtesting/engine.py b/dashboard/backend/domain/backtesting/engine.py index e927a3c..758416e 100644 --- a/dashboard/backend/domain/backtesting/engine.py +++ b/dashboard/backend/domain/backtesting/engine.py @@ -14,10 +14,9 @@ be extracted in a later phase. """ -import sys import uuid from datetime import datetime -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from dashboard.backend.database import db import dashboard.backend.infrastructure.llm.token_cost as token_cost @@ -30,7 +29,11 @@ calculate_max_drawdown, ) from dashboard.backend.domain.backtesting.portfolio_manager import PortfolioManager -from dashboard.backend.infrastructure.market_data.alpaca_bars import AlpacaDataLoader +from dashboard.backend.infrastructure.market_data.alpaca_bars import ( + AlpacaDataLoader, + MarketDataUnavailableError, +) +import dashboard.backend.infrastructure.llm.backtest_harness as llm_harness from dashboard.backend.infrastructure.llm.backtest_harness import ( HAS_ANTHROPIC, default_model_name, @@ -85,8 +88,13 @@ def load_data(self): """Fetch hourly data from Alpaca.""" self.all_data = self.data_loader.fetch_bars(DJIA_30, self.start_date, self.end_date) if not self.all_data: - print("❌ No data fetched. Exiting.") - sys.exit(1) + # Raise, don't sys.exit(1): this runs inside server threads + # (external runs, algo service) where SystemExit evades + # `except Exception` and strands the run (the B0 class). + print("❌ No data fetched.") + raise MarketDataUnavailableError( + f"No market data available for {self.start_date}..{self.end_date}" + ) def calculate_indicators(self): """Calculate technical indicators for all symbols.""" @@ -99,6 +107,17 @@ def calculate_indicators(self): print(f" ✅ {count}/{len(self.all_data)} symbols...") print(f" ✅ All indicators calculated\n") + def _llm_run_metadata(self) -> Optional[Dict]: + """Config snapshot recorded on the agent run row. + + LLM_MAX_OUTPUT_TOKENS is an env knob that changes a run's spend and + response truncation; recording the EFFECTIVE value (post defensive + parse) makes runs auditable after the env changes. Rule-based runs + record nothing.""" + if not self.use_llm: + return None + return {"llm_max_output_tokens": llm_harness.DEFAULT_MAX_OUTPUT_TOKENS} + def run_agent_backtest(self) -> Tuple[str, List[Dict]]: """Run backtest with agent making hourly decisions.""" print("🤖 Running Agent backtest (hourly decisions)...\n") @@ -245,8 +264,9 @@ def run_agent_backtest(self) -> Tuple[str, List[Dict]]: input_tokens=manager.input_tokens, output_tokens=manager.output_tokens, est_cost_usd=est_cost, + metadata=self._llm_run_metadata(), ) - + db.insert_equity_points(run_id, equity_curve) print(f"\n ✅ Agent backtest complete") diff --git a/dashboard/backend/domain/backtesting/external_run_service.py b/dashboard/backend/domain/backtesting/external_run_service.py index a6e2d70..6e8cf39 100644 --- a/dashboard/backend/domain/backtesting/external_run_service.py +++ b/dashboard/backend/domain/backtesting/external_run_service.py @@ -26,6 +26,7 @@ import dashboard.backend.infrastructure.llm.token_cost as token_cost from dashboard.backend.domain.agents.repository import agent_store from dashboard.backend.database import db +from dashboard.backend.execution.base import TERMINAL_STATUSES from dashboard.backend.infrastructure.llm.validator import ( DJIA_30, actions_to_executable, @@ -71,6 +72,46 @@ def _new_ext_run_id() -> str: return f"ext_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" +def build_compare_url( + run_id: Optional[str], baseline_run_ids: Dict[str, str], +) -> Optional[str]: + """Compare-view link: the run against its baselines (run, djia, buy&hold). + + Module-level so the live session and the archived-run tombstone build the + exact same URL — the ordering is part of the contract (finding: the + tombstone used to drop compare_url entirely).""" + if not run_id: + return None + ids = [run_id] + if baseline_run_ids.get("djia"): + ids.append(baseline_run_ids["djia"]) + if baseline_run_ids.get("buy_and_hold"): + ids.append(baseline_run_ids["buy_and_hold"]) + return f"/compare?run_ids={','.join(ids)}" + + +def build_final_metrics(run: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """The completed-run metrics block, from an agent_runs row. + + Module-level (like build_compare_url) so the live session's get_status and + the archived-run tombstone hand out the exact same shape — a new metric + can't be added to one surface and silently dropped from the other. Returns + {} for a missing row.""" + if not run: + return {} + return { + "total_return": run.get("total_return"), + "sharpe_ratio": run.get("sharpe_ratio"), + "max_drawdown": run.get("max_drawdown"), + "num_trades": run.get("num_trades"), + "final_equity": run.get("final_equity"), + "llm_calls": run.get("llm_calls"), + "input_tokens": run.get("input_tokens"), + "output_tokens": run.get("output_tokens"), + "est_cost_usd": run.get("est_cost_usd"), + } + + class ExternalBacktestSession: """One external-agent backtest driven hour-by-hour through the API.""" @@ -149,8 +190,16 @@ def load_market_data(self) -> None: if self.total_steps == 0: raise RuntimeError("No trading hours in the selected date range") - self.status = "waiting_decision" - self._open_current_step() + # Publish the loaded state under the step lock, and only if a concurrent + # cancel() hasn't already moved the run to a terminal state. cancel() + # writes "closed" under this same lock; this write used to be unlocked + # and would resurrect a cancelled run back to waiting_decision (freeing + # its cap slot while it kept running). _open_current_step takes no lock. + with self._step_lock: + if self.status in TERMINAL_STATUSES: + return + self.status = "waiting_decision" + self._open_current_step() def _build_trading_timestamps(self) -> List[Any]: all_timestamps: set = set() @@ -543,6 +592,16 @@ def _finalize(self) -> None: db.insert_trades(self.run_id, self.manager.trades) db.insert_decisions(self.run_id, self.decision_log) + # The run is fully persisted now — publish "completed" BEFORE the slow, + # best-effort baseline generation below. is_active()/cancel() read + # self.status without _step_lock; if the flip waited until after the + # baselines (two full backtests, seconds-to-minutes, all under the step + # lock) a cancel would block for that whole window and the cap would + # keep counting a finished run. baseline_run_ids fills in just below, + # still before _finalize returns, so the locked callers see the + # complete envelope. + self.status = "completed" + try: backtester = HourlyBacktester( self.start_date, @@ -564,11 +623,11 @@ def _finalize(self) -> None: buyhold_run_id=buyhold_id, ) # Baseline generation is strictly best-effort and must never break — or - # hang — run finalization. AlpacaDataLoader raises SystemExit (a - # BaseException, not Exception) when credentials are absent; if that - # escaped here it would propagate into the ASGI worker and wedge the - # request future forever. Catch SystemExit alongside Exception so a - # credential-less environment degrades to "run saved, no baselines". + # hang — run finalization. A credential-less environment now raises + # MarketDataUnavailableError (a plain Exception, B0 deep fix), but the + # SystemExit catch stays as defense-in-depth: any regression back to a + # BaseException here would propagate into the ASGI worker and wedge + # the request future forever (the original B0 hang). except (Exception, SystemExit) as exc: print(f"⚠️ Baseline generation failed (run saved): {exc}") @@ -580,36 +639,15 @@ def _finalize(self) -> None: ) except Exception as exc: print(f"⚠️ Agent auto-register failed (run saved): {exc}") - - self.status = "completed" + # status is already "completed" (set before the baseline block above). def _compare_url(self) -> Optional[str]: - if not self.run_id: - return None - ids = [self.run_id] - if self.baseline_run_ids.get("djia"): - ids.append(self.baseline_run_ids["djia"]) - if self.baseline_run_ids.get("buy_and_hold"): - ids.append(self.baseline_run_ids["buy_and_hold"]) - return f"/compare?run_ids={','.join(ids)}" + return build_compare_url(self.run_id, self.baseline_run_ids) def _final_metrics(self) -> Dict[str, Any]: if not self.run_id: return {} - run = db.get_run(self.run_id) - if not run: - return {} - return { - "total_return": run.get("total_return"), - "sharpe_ratio": run.get("sharpe_ratio"), - "max_drawdown": run.get("max_drawdown"), - "num_trades": run.get("num_trades"), - "final_equity": run.get("final_equity"), - "llm_calls": run.get("llm_calls"), - "input_tokens": run.get("input_tokens"), - "output_tokens": run.get("output_tokens"), - "est_cost_usd": run.get("est_cost_usd"), - } + return build_final_metrics(db.get_run(self.run_id)) def get_status(self) -> Dict[str, Any]: with self._step_lock: @@ -776,13 +814,12 @@ def start_backtest( _sessions[backtest_id] = session def _load_in_background() -> None: - # load_market_data() constructs AlpacaDataLoader, which raises SystemExit - # (a BaseException, not Exception) when credentials are absent. On this - # daemon thread the default threading.excepthook silently swallows - # SystemExit, so the thread would die without ever marking the session - # failed — leaving the run stuck in "loading" forever. Catch SystemExit - # alongside Exception so a credential-less environment fails the run - # cleanly instead of stranding it. (Mirrors the _finalize() catch above.) + # load_market_data() constructs AlpacaDataLoader; missing credentials + # now raise MarketDataUnavailableError (a plain Exception, B0 deep + # fix). The SystemExit catch stays as defense-in-depth: on this daemon + # thread the default threading.excepthook silently swallows + # SystemExit, so a regression back to sys.exit() would strand the run + # in "loading" forever. (Mirrors the _finalize() catch above.) try: session.load_market_data() except (Exception, SystemExit) as exc: diff --git a/dashboard/backend/domain/backtesting/portfolio_manager.py b/dashboard/backend/domain/backtesting/portfolio_manager.py index b34c103..da304ad 100644 --- a/dashboard/backend/domain/backtesting/portfolio_manager.py +++ b/dashboard/backend/domain/backtesting/portfolio_manager.py @@ -64,7 +64,8 @@ def __init__(self, initial_capital: float = 100000): self.trades = [] self.equity_history = [] # Real LLM token usage (server-side calls report actual counts) - self.llm_calls = 0 + self.llm_calls = 0 # billed API calls (any response with usage) + self.llm_decisions = 0 # steps the model actually drove (H6 coverage) self.input_tokens = 0 self.output_tokens = 0 @@ -392,9 +393,18 @@ def _trend_score(sig: Dict) -> float: # else: HOLD is implicit (don't add to actions) + # Count this step as model-driven only here, at the single success + # exit — the model's actions were parsed AND processed without error, + # so the returned decision is genuinely the model's. This is the H6 + # coverage numerator, distinct from llm_calls (billed calls): any + # exception in the processing loop above is caught below and swapped + # for a rule-based decision, which must NOT count. (Actions filtered + # for low confidence / invalid symbol still count: the model drove + # the step, our policy merely declined to act on it.) + self.llm_decisions += 1 print(f" ✅ Total actions: {len(actions)}\n") return {"actions": actions} - + except Exception as e: print(f"\n❌ LLM decision error: {e}") print(f" Falling back to rule-based logic\n") diff --git a/dashboard/backend/domain/leaderboard/service.py b/dashboard/backend/domain/leaderboard/service.py index 8629faf..3594350 100644 --- a/dashboard/backend/domain/leaderboard/service.py +++ b/dashboard/backend/domain/leaderboard/service.py @@ -28,6 +28,14 @@ LEADERBOARD_MODE = "leaderboard" +# H6 integrity threshold: an LLM entry must have decided at least this fraction +# of its steps with the model itself. Below it, the curve is mostly a rule-based +# fallback and publishing it would misrepresent that model's result. 0.95 leaves +# a small margin for transient API blips on a genuine run (e.g. 159/161) while +# still rejecting partial-fallback curves (e.g. the 1/161 run that topped the +# board). Override per-deploy with allow_fallback=True / --allow-fallback. +MIN_LLM_DECISION_COVERAGE = 0.95 + def _auto_compute(strategy: Dict[str, Any]) -> bool: """Whether a strategy is cheap enough to compute on-demand during a web request. @@ -174,7 +182,10 @@ def ensure_leaderboard_runs(force_refresh: bool = False) -> Dict[str, Any]: strategy_id, strategy_impl, int(getattr(strategy_impl, "llm_calls", 0) or 0), + llm_decisions=_reported_int(strategy_impl, "llm_decisions"), + decision_steps=int(getattr(strategy_impl, "decision_steps", 0) or 0), model=strategy.get("model"), + model_id=getattr(strategy_impl, "model_id", None) or strategy.get("model_id"), ) db.insert_run( @@ -211,24 +222,56 @@ class LeaderboardFallbackError(RuntimeError): model's result. Override deliberately with ``allow_fallback=True``.""" +def _reported_int(strategy_impl: Any, name: str) -> Optional[int]: + """Read an int counter a strategy *may* report. Returns ``None`` when the + attribute is absent so the guard can apply its documented default (e.g. + ``llm_decisions`` → ``llm_calls``); a present value (including a real 0) is + coerced to int. Distinguishing absent-from-zero matters: a genuine 0 means + "the model drove no step" (reject), while absent means "this strategy shape + doesn't report it" (fall back to llm_calls).""" + val = getattr(strategy_impl, name, None) + return None if val is None else int(val) + + def _reject_if_llm_fallback( entry_id: str, strategy_impl: Any, llm_calls: int, *, + llm_decisions: Optional[int] = None, + decision_steps: int = 0, model: Optional[str] = None, model_id: Optional[str] = None, allow_fallback: bool = False, ) -> None: """Integrity guard (H6): refuse to publish an LLM entry that silently fell - back to rule-based trading — no client (missing key/SDK) or a model id the - active gateway rejected so every call failed. Publishing it would present a - rule-based curve as if the model produced it. Rule-based baselines expose no - ``used_llm`` (getattr → None) and pass through untouched. Applied on BOTH - insert paths so an LLM entry can't slip through the auto-compute path.""" + back to rule-based trading. Two shapes of fallback are caught: + + - **Total fallback** — no client (missing key/SDK) or a model id the active + gateway rejected so every call failed (``used_llm`` False or ``llm_calls`` + 0). The whole curve is rule-based. + - **Partial fallback** — the client responded but most steps produced no + usable decision (``llm_decisions / decision_steps`` below + ``MIN_LLM_DECISION_COVERAGE``). The curve is *mostly* rule-based, so + publishing it still misrepresents the model (this is the 1-of-161 run that + silently topped the board). + + Coverage keys off ``llm_decisions`` — steps the model actually drove — not + ``llm_calls`` (billed API calls), because a truncated / unparseable response + is billed yet trades rule-based. A run that returns garbage every step has + ``llm_calls == decision_steps`` but ``llm_decisions == 0``, and must still be + refused. ``llm_decisions`` defaults to ``llm_calls`` for callers (or older + strategy objects) that don't report it separately. + + Rule-based baselines expose no ``used_llm`` (getattr → None) and pass through + untouched. Applied on BOTH insert paths so an LLM entry can't slip through + the auto-compute path. Coverage is only checked when ``decision_steps`` is + known (> 0); a genuine run always reports it.""" used_llm = getattr(strategy_impl, "used_llm", None) if used_llm is None or allow_fallback: return + if llm_decisions is None: + llm_decisions = llm_calls if not used_llm or llm_calls == 0: raise LeaderboardFallbackError( f"Entry '{entry_id}' produced a rule-based fallback " @@ -237,6 +280,18 @@ def _reject_if_llm_fallback( f"for the active LLM gateway, or the API key is missing. Pass " f"allow_fallback=True / --allow-fallback to publish it anyway." ) + if decision_steps > 0 and llm_decisions < MIN_LLM_DECISION_COVERAGE * decision_steps: + coverage = llm_decisions / decision_steps + raise LeaderboardFallbackError( + f"Entry '{entry_id}' is a partial rule-based fallback: only " + f"{llm_decisions}/{decision_steps} steps ({coverage:.1%}) produced a " + f"usable model decision, below the {MIN_LLM_DECISION_COVERAGE:.0%} " + f"threshold. Most of the curve is rule-based, so refusing to publish " + f"it under model '{model}'. Usually the model id '{model_id}' " + f"intermittently failed for the active LLM gateway (e.g. rate limits " + f"or output truncated into invalid JSON). Pass allow_fallback=True / " + f"--allow-fallback to publish it anyway." + ) def deploy_model_run( @@ -302,6 +357,8 @@ def deploy_model_run( input_tokens = int(getattr(strategy_impl, "input_tokens", 0) or 0) output_tokens = int(getattr(strategy_impl, "output_tokens", 0) or 0) llm_calls = int(getattr(strategy_impl, "llm_calls", 0) or 0) + llm_decisions = _reported_int(strategy_impl, "llm_decisions") + decision_steps = int(getattr(strategy_impl, "decision_steps", 0) or 0) model_id = getattr(strategy_impl, "model_id", None) or entry.get("model_id") est_cost = token_cost.estimate_cost_usd(model_id, input_tokens, output_tokens) @@ -309,6 +366,8 @@ def deploy_model_run( entry_id, strategy_impl, llm_calls, + llm_decisions=llm_decisions, + decision_steps=decision_steps, model=entry.get("model"), model_id=model_id, allow_fallback=allow_fallback, diff --git a/dashboard/backend/domain/leaderboard/strategies/llm_agent.py b/dashboard/backend/domain/leaderboard/strategies/llm_agent.py index c473b01..5a74fd6 100644 --- a/dashboard/backend/domain/leaderboard/strategies/llm_agent.py +++ b/dashboard/backend/domain/leaderboard/strategies/llm_agent.py @@ -39,6 +39,8 @@ def __init__(self, config: Dict[str, Any]): self.model_id = self.config.get("model_id") # Populated during run() for reporting / cost tracking. self.llm_calls = 0 + self.llm_decisions = 0 # steps the model actually drove (H6 guard numerator) + self.decision_steps = 0 # total steps offered to the model (guard denominator) self.input_tokens = 0 self.output_tokens = 0 self._num_trades = 0 @@ -128,6 +130,8 @@ def run( self._num_trades = len(manager.trades) self.llm_calls = manager.llm_calls + self.llm_decisions = manager.llm_decisions # steps the model actually drove + self.decision_steps = total # how many steps the model was asked to decide self.input_tokens = manager.input_tokens self.output_tokens = manager.output_tokens self.model_id = model_id diff --git a/dashboard/backend/domain/runs/repository.py b/dashboard/backend/domain/runs/repository.py index a4a87fa..fbbb862 100644 --- a/dashboard/backend/domain/runs/repository.py +++ b/dashboard/backend/domain/runs/repository.py @@ -17,11 +17,17 @@ import json import sqlite3 import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional -from dashboard.backend.database import DB_PATH +from dashboard.backend.database import DB_PATH, enable_wal + +# Identifies this process in protocol_runs.owner_instance. Fresh per process on +# purpose: after a crash/restart the old instance's rows stop being heartbeated +# and become recoverable by ANY instance via fail_stale_runs (multi-worker-safe, +# unlike the blanket startup recovery). +INSTANCE_ID = uuid.uuid4().hex[:12] def _utcnow_iso() -> str: @@ -49,6 +55,8 @@ def _public_run(row: sqlite3.Row | Dict[str, Any]) -> Dict[str, Any]: "backtest_id": data.get("backtest_id"), "result_run_id": data.get("result_run_id"), "status": data.get("status"), + "step_index": data.get("step_index"), + "total_steps": data.get("total_steps"), "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), } @@ -60,6 +68,7 @@ class RunStore: def __init__(self, db_path: Path | None = None): self.db_path = Path(db_path or DB_PATH) self.db_path.parent.mkdir(parents=True, exist_ok=True) + enable_wal(self.db_path) # shared helper — one definition for both layers self._init_schema() def _get_connection(self) -> sqlite3.Connection: @@ -87,10 +96,20 @@ def _init_schema(self) -> None: result_run_id TEXT, status TEXT NOT NULL DEFAULT 'created', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + owner_instance TEXT, + heartbeat_at TEXT, + step_index INTEGER, + total_steps INTEGER ) """ ) + # Existing DBs predate the recovery/step columns; CREATE TABLE IF NOT + # EXISTS won't add them, so probe and ALTER (same pattern as the + # main DB's _migrate_schema). + cursor.execute("PRAGMA table_info(protocol_runs)") + columns = {row[1] for row in cursor.fetchall()} + self._add_recovery_columns(cursor, columns) cursor.execute( "CREATE INDEX IF NOT EXISTS idx_protocol_runs_agent ON protocol_runs(agent_id)" ) @@ -121,19 +140,49 @@ def _init_schema(self) -> None: conn.commit() conn.close() + @staticmethod + def _add_recovery_columns(cursor, existing_columns) -> None: + """ALTER in the recovery + step-count columns missing from + ``existing_columns`` (owner_instance/heartbeat_at for multi-worker + recovery; step_index/total_steps so a rehydrated terminal run reports + real progress instead of 0/0). + + Tolerates losing the probe→ALTER race to a concurrently-starting + sibling process (multi-worker startup): a duplicate-column error + means the column is there, which is the goal — anything else is + real and re-raised.""" + for col_name, col_def in ( + ("owner_instance", "TEXT"), + ("heartbeat_at", "TEXT"), + ("step_index", "INTEGER"), + ("total_steps", "INTEGER"), + ): + if col_name in existing_columns: + continue + try: + cursor.execute( + f"ALTER TABLE protocol_runs ADD COLUMN {col_name} {col_def}" + ) + except sqlite3.OperationalError as exc: + if "duplicate column" not in str(exc).lower(): + raise + def create_run( self, *, agent_id: Optional[str], agent_version_id: Optional[str], session_id: str, - environment_id: str, + environment_id: Optional[str], environment_type: str, config: Dict[str, Any], backtest_id: Optional[str] = None, status: str = "created", + run_id: Optional[str] = None, ) -> Dict[str, Any]: - run_id = _new_run_id() + # run_id: v2 mints its own canonical id before creating the backend; + # v1 leaves it None and gets a run_ id minted here. + run_id = run_id or _new_run_id() now = _utcnow_iso() conn = self._get_connection() cursor = conn.cursor() @@ -142,8 +191,8 @@ def create_run( INSERT INTO protocol_runs ( run_id, agent_id, agent_version_id, session_id, environment_id, environment_type, config, backtest_id, result_run_id, status, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, owner_instance, heartbeat_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( run_id, @@ -158,6 +207,8 @@ def create_run( status, now, now, + INSTANCE_ID, + now, ), ) conn.commit() @@ -173,6 +224,8 @@ def update_run( backtest_id: Optional[str] = None, result_run_id: Optional[str] = None, status: Optional[str] = None, + step_index: Optional[int] = None, + total_steps: Optional[int] = None, ) -> None: conn = self._get_connection() cursor = conn.cursor() @@ -182,10 +235,13 @@ def update_run( SET backtest_id = COALESCE(?, backtest_id), result_run_id = COALESCE(?, result_run_id), status = COALESCE(?, status), + step_index = COALESCE(?, step_index), + total_steps = COALESCE(?, total_steps), updated_at = ? WHERE run_id = ? """, - (backtest_id, result_run_id, status, _utcnow_iso(), run_id), + (backtest_id, result_run_id, status, step_index, total_steps, + _utcnow_iso(), run_id), ) conn.commit() conn.close() @@ -247,6 +303,48 @@ def fail_unfinished_runs(self) -> int: conn.close() return int(updated) + def heartbeat_runs(self, run_ids: List[str]) -> None: + """Refresh heartbeat_at (and claim owner_instance) for live runs. + + Called by the reaper each pass for every run whose engine session is + alive in this process — a run that keeps heartbeating is never + eligible for fail_stale_runs, no matter which instance created it.""" + if not run_ids: + return + placeholders = ",".join("?" for _ in run_ids) + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + f"UPDATE protocol_runs SET heartbeat_at = ?, owner_instance = ? " + f"WHERE run_id IN ({placeholders})", + (_utcnow_iso(), INSTANCE_ID, *run_ids), + ) + conn.commit() + conn.close() + + def fail_stale_runs(self, stale_seconds: float) -> int: + """Fail non-terminal runs whose heartbeat stopped (multi-worker-safe + recovery). Unlike fail_unfinished_runs' blanket UPDATE, this only + touches rows no live process is heartbeating, so one worker's startup + can't kill a sibling's in-flight runs. Rows from before the heartbeat + column fall back to updated_at. Returns rows updated.""" + cutoff = ( + datetime.now(timezone.utc) - timedelta(seconds=stale_seconds) + ).replace(microsecond=0).isoformat() + placeholders = ",".join("?" for _ in self._ACTIVE_STATUSES) + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + f"UPDATE protocol_runs SET status = 'failed', updated_at = ? " + f"WHERE status IN ({placeholders}) " + f"AND COALESCE(heartbeat_at, updated_at, created_at) < ?", + (_utcnow_iso(), *self._ACTIVE_STATUSES, cutoff), + ) + updated = cursor.rowcount + conn.commit() + conn.close() + return int(updated) + # -- protocol_steps: persisted step bookkeeping (H4 follow-up) --------- def save_step(self, run_id: str, step_id: str, sequence: int, diff --git a/dashboard/backend/domain/runs/service.py b/dashboard/backend/domain/runs/service.py index a89080a..121d37b 100644 --- a/dashboard/backend/domain/runs/service.py +++ b/dashboard/backend/domain/runs/service.py @@ -24,11 +24,12 @@ import threading import time import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import dashboard.backend.domain.backtesting.external_run_service as ebs from dashboard.backend.database import db from dashboard.backend.domain.backtesting.constants import INITIAL_CAPITAL +from dashboard.backend.execution.base import TERMINAL_STATUSES from dashboard.backend.domain.runs.environment import get_environment from dashboard.backend.infrastructure.llm.validator import DJIA_30, MAX_ORDER_SHARES from dashboard.backend.domain.runs.protocol import ( @@ -50,14 +51,36 @@ REAPER_INTERVAL_SECONDS = float(os.getenv("RUN_REAPER_INTERVAL_SECONDS", "60")) # Startup recovery marks ALL non-terminal rows failed; only correct when a single # process owns the DB (the current single-instance deployment). A multi-worker or -# overlapping rolling-deploy setup sharing one DB must disable it (set to 0). +# overlapping rolling-deploy setup sharing one DB must disable it (set to 0) and +# rely on heartbeat-based stale recovery instead (RUN_HEARTBEAT_STALE_SECONDS): +# the reaper heartbeats every live run each pass, so only runs whose owning +# process died go stale and get failed — a sibling worker's runs are safe. RUN_RECOVERY_ON_STARTUP = os.getenv("RUN_RECOVERY_ON_STARTUP", "1").lower() not in ("0", "false", "no") +# How long a non-terminal run may go without a heartbeat before any instance's +# reaper declares its owner dead and fails it. Must be comfortably larger than +# REAPER_INTERVAL_SECONDS (heartbeat cadence); 0 disables stale recovery. +RUN_HEARTBEAT_STALE_SECONDS = float(os.getenv("RUN_HEARTBEAT_STALE_SECONDS", "300")) _reaper_thread: Optional[threading.Thread] = None _reaper_lock = threading.Lock() # Serializes create_run so the per-agent active-run cap can't be raced past. +# Shared with the v2 create path (api/v2/runs.py) — both surfaces count active +# runs from the same protocol_runs ledger, so both check-then-insert sequences +# must serialize on the same lock or an agent could race one create per surface +# past the combined cap. _create_lock = threading.Lock() +# Extra per-pass reaper work registered by other surfaces (the composition root +# registers the v2 sweep here). Kept as a registry so this domain module never +# imports api/* (layering: domain must not depend on the API layer). +_extra_reaper_sweeps: List[Callable[[], Any]] = [] + + +def register_reaper_sweep(sweep: Callable[[], Any]) -> None: + """Register a callable the reaper invokes once per pass (idempotent).""" + if sweep not in _extra_reaper_sweeps: + _extra_reaper_sweeps.append(sweep) + # Engine status -> protocol run status _RUN_STATUS_MAP = { "loading": "running", @@ -248,16 +271,24 @@ def reap_runs() -> int: with _registry_lock: runs = list(_runs.values()) reaped = 0 + live_run_ids: List[str] = [] for run in runs: try: session = run.session() if session is not None: session.drain_expired() _sync_status(run) - if run.status in ("completed", "failed") and run.backtest_id: + if run.status in TERMINAL_STATUSES and run.backtest_id: if ebs.evict_session(run.backtest_id): reaped += 1 - if run.status in ("completed", "failed"): + elif run.status not in TERMINAL_STATUSES: + # Live in this process: keep its heartbeat fresh so no + # sibling instance's stale recovery can fail it. A + # non-terminal run WITHOUT a session is deliberately not + # heartbeated — it can never progress here, so letting it + # go stale is exactly how it gets recovered. + live_run_ids.append(run.run_id) + if run.status in TERMINAL_STATUSES: # Terminal state is DB-backed (protocol_runs + protocol_steps); # keeping the ProtocolRun would only grow _runs forever. But a # request may be mid-flight on this run (holding run.lock, its @@ -272,6 +303,25 @@ def reap_runs() -> int: run.lock.release() except Exception as exc: # a single wedged run must not stall the sweep print(f"⚠️ reap_runs: skipping {run.run_id}: {exc}") + try: + run_store.heartbeat_runs(live_run_ids) + except Exception as exc: + print(f"⚠️ reap_runs: heartbeat pass failed: {exc}") + # Other surfaces' sweeps (e.g. v2: drain deadlines, heartbeat, archive + # terminal backends) — registered via register_reaper_sweep so they run + # BEFORE stale recovery and their live runs never look abandoned. + for sweep in list(_extra_reaper_sweeps): + try: + sweep() + except Exception as exc: + print(f"⚠️ reap_runs: registered sweep failed: {exc}") + if RUN_HEARTBEAT_STALE_SECONDS > 0: + try: + stale = run_store.fail_stale_runs(RUN_HEARTBEAT_STALE_SECONDS) + if stale: + print(f"🧹 stale-run recovery: failed {stale} abandoned run(s)") + except Exception as exc: + print(f"⚠️ reap_runs: stale recovery failed: {exc}") return reaped diff --git a/dashboard/backend/execution/backtest_backend.py b/dashboard/backend/execution/backtest_backend.py index d95fc50..b81377e 100644 --- a/dashboard/backend/execution/backtest_backend.py +++ b/dashboard/backend/execution/backtest_backend.py @@ -14,9 +14,12 @@ from dashboard.backend.api.v2.errors import ApiError from dashboard.backend.api.v2.models import SCHEMA_VERSION from dashboard.backend.database import db -from dashboard.backend.execution.base import ExecutionBackend +from dashboard.backend.execution.base import ExecutionBackend, TERMINAL_STATUSES from dashboard.backend.infrastructure.llm.validator import DJIA_30 from dashboard.backend.domain.backtesting import external_run_service as ext +# Late-bound module reference (run_repo.run_store) so tests that swap the +# run_store singleton cover this module too. +from dashboard.backend.domain.runs import repository as run_repo def load_news_sentiment(universe: List[str], timestamp: Any) -> Tuple[Dict[str, Any], Optional[str]]: @@ -66,15 +69,39 @@ def _load() -> None: try: self.session.load_market_data() except (Exception, SystemExit) as exc: # mirror v1 start_backtest behavior - # SystemExit too: AlpacaDataLoader sys.exit()s when creds are - # absent, and a daemon thread swallows an uncaught SystemExit - # silently — the run would sit in "loading" forever. + # Missing creds now raise MarketDataUnavailableError (plain + # Exception, B0 deep fix); the SystemExit catch stays as + # defense-in-depth — a daemon thread swallows an uncaught + # SystemExit silently and the run would sit in "loading" forever. # Take the session lock: HTTP readers inspect status/error under # the same lock (get_current_step/get_status), so the writer must - # hold it too to avoid a data race on these fields. + # hold it too to avoid a data race on these fields. A cancel() + # that won during the load already wrote a terminal "closed"; + # don't clobber it (mirror cancel()'s own terminal guard). with self.session._step_lock: - self.session.status = "failed" - self.session.error = str(exc) + if self.session.status not in TERMINAL_STATUSES: + self.session.status = "failed" + self.session.error = str(exc) + final_status = self.session.status + # Mirror the run's true terminal state into its protocol_runs row + # so the unified active-run cap frees this slot without waiting + # for the reaper's next reconcile pass. + try: + run_repo.run_store.update_run(self.run_id, status=final_status) + except Exception: + pass # row bookkeeping is best-effort; the sweep reconciles + return + # Loaded. Advance the row to 'running' only if the run wasn't + # cancelled/finished while loading — load_market_data leaves a + # terminal status untouched under the lock, so reviving it to + # 'running' here would resurrect a run the agent already cancelled. + with self.session._step_lock: + status_now = self.session.status + if status_now not in TERMINAL_STATUSES: + try: + run_repo.run_store.update_run(self.run_id, status="running") + except Exception: + pass # best-effort; both statuses count as active anyway self.session.status = "loading" threading.Thread(target=_load, daemon=True).start() @@ -170,7 +197,7 @@ def apply_decisions(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]: for e in (result.get("executed") or []) ] - return { + ack = { "accepted": bool(result.get("accepted", True)), "executed": executed, "rejected": [], @@ -180,16 +207,45 @@ def apply_decisions(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]: "run_id": self.run_id, "metrics": result.get("metrics"), } + if ack["status"] == "completed": + # The final submit finalized the run — record the terminal state on + # its protocol_runs row so cap counting and restart recovery see it. + try: + run_repo.run_store.update_run( + self.run_id, + status="completed", + result_run_id=self.session.run_id or self.run_id, + ) + except Exception: + pass # best-effort; the reaper sweep reconciles + return ack def decisions(self) -> List[Dict[str, Any]]: return self.session.get_decisions() def advance(self) -> None: - # Lockstep engine advances inside submit; this only applies a pending timeout. - self.session.get_current_step() + # Lockstep engine advances inside submit; this only applies a pending + # timeout. drain_expired() does exactly that (same auto-hold loop the v1 + # reaper uses) without get_current_step()'s discarded market-snapshot + # rebuild — this runs every reaper pass, per live run, under _step_lock. + self.session.drain_expired() def cancel(self) -> None: - self.session.status = "closed" + # Cancel only closes a run that is still running — never clobber a + # terminal state. A finalizing submit racing a cancel would otherwise + # flip completed → closed, and every later read (row reconcile, + # archive, metrics) derives from this field. + # + # Fast path: a lock-free status read (same pattern as is_active). If the + # run is already terminal — including "completed", which _finalize + # publishes BEFORE its slow baseline block — return without taking the + # lock, so a cancel never blocks for the finalize/baseline duration + # (seconds-to-minutes held under _step_lock). + if self.session.status in TERMINAL_STATUSES: + return + with self.session._step_lock: + if self.session.status not in TERMINAL_STATUSES: + self.session.status = "closed" # -- status / result --------------------------------------------------- @@ -204,7 +260,7 @@ def is_active(self) -> bool: # global create lock and must never take _step_lock (get_status can # cascade into _maybe_apply_timeout/_finalize). Worst case the cap # briefly counts a just-finished run — fine for a resource cap. - return self.session.status not in ("completed", "failed", "closed") + return self.session.status not in TERMINAL_STATUSES def result(self) -> Optional[Dict[str, Any]]: if not self.session.run_id: @@ -214,3 +270,150 @@ def result(self) -> Optional[Dict[str, Any]]: return None base["manifest"] = db.get_run_manifest(self.run_id) return base + + +class ArchivedBacktestBackend(ExecutionBackend): + """DB-backed tombstone for a terminal run whose engine session was freed. + + The reaper swaps this in for a finished BacktestBackend (releasing the + market-data buffers — ~99% of a session's memory), and the v2 API + rehydrates one from a terminal protocol_runs row after a restart. Reads + (status/result/decisions and DB-cached idempotent replays) keep working; + new decisions get the same invalid_status rejection the live path gives + for a terminal run. + """ + + loop = "lockstep" + + _TERMINAL = TERMINAL_STATUSES + + def __init__(self, *, run_id: str, session_id: str, status: str, + error: Optional[str] = None, step_index: int = 0, + total_steps: int = 0, result_run_id: Optional[str] = None): + self.run_id = run_id + self.session_id = session_id + self.terminal_status = status if status in self._TERMINAL else "failed" + self.error = error + self.step_index = int(step_index or 0) + self.total_steps = int(total_steps or 0) + self.result_run_id = result_run_id or run_id + + @classmethod + def from_record(cls, record: Dict[str, Any]) -> "ArchivedBacktestBackend": + """Rebuild from a terminal protocol_runs row (post-restart reads). + + Step counts are persisted on the row at archive time (step_index / + total_steps), so a failed/closed run reports its real progress instead + of 0/0. Legacy rows that predate those columns fall back to the + completed run's decision log (one row per executed step); a legacy + failed run has no such log and stays 0/0, which is genuinely + unrecoverable.""" + status = record.get("status") or "failed" + result_run_id = record.get("result_run_id") or record["run_id"] + step_index = record.get("step_index") + total_steps = record.get("total_steps") + if (step_index is None or total_steps is None) and status == "completed": + try: + count = len(db.get_decisions(result_run_id)) + step_index = count if step_index is None else step_index + total_steps = count if total_steps is None else total_steps + except Exception: + pass + step_index = step_index or 0 + total_steps = total_steps or 0 + return cls( + run_id=record["run_id"], + session_id=record["session_id"], + status=status, + result_run_id=record.get("result_run_id"), + step_index=step_index, + total_steps=total_steps, + ) + + # -- lifecycle: everything is over ------------------------------------- + + def is_active(self) -> bool: + return False + + def current_step_index(self) -> int: + return self.step_index + + def advance(self) -> None: + return None + + def cancel(self) -> None: + return None # already terminal; do not clobber completed → closed + + # -- reads -------------------------------------------------------------- + + def build_context(self) -> Dict[str, Any]: + # Mirrors the live backend's non-waiting envelope. + return { + "schema_version": SCHEMA_VERSION, + "run_id": self.run_id, + "mode": "backtest", + "loop": self.loop, + "universe": list(DJIA_30), + "status": self.terminal_status, + "step_index": self.step_index, + "total_steps": self.total_steps, + "news_sentiment": {}, + "news_overview": None, + } + + def apply_decisions(self, actions: List[Dict[str, Any]]) -> Dict[str, Any]: + # Same errors the live path raises for a terminal session (via + # _raise_rejection), so archival is invisible to error handling. + if self.terminal_status == "completed": + raise ApiError("invalid_status", "Run already completed", status=409) + raise ApiError( + "invalid_status", + f"Run is not awaiting a decision (status: {self.terminal_status})", + status=409, + ) + + def status(self) -> Dict[str, Any]: + base: Dict[str, Any] = { + "backtest_id": self.run_id, + "status": self.terminal_status, + "step_index": self.step_index, + "total_steps": self.total_steps, + "run_id": self.result_run_id, + "mode": "backtest", + "loop": self.loop, + } + manifest = db.get_run_manifest(self.run_id) or {} + base["agent_name"] = manifest.get("agent_name") + base["model_name"] = manifest.get("model_name") + if self.terminal_status == "completed": + row = db.get_run(self.result_run_id) + if row: + base["metrics"] = ext.build_final_metrics(row) + # The live get_status() carries baseline_run_ids + compare_url + # for a completed run; rebuild them from the persisted baseline + # columns so archival doesn't silently drop those fields. + baseline_run_ids: Dict[str, str] = {} + if row.get("baseline_buyhold_run_id"): + baseline_run_ids["buy_and_hold"] = row["baseline_buyhold_run_id"] + if row.get("baseline_djia_run_id"): + baseline_run_ids["djia"] = row["baseline_djia_run_id"] + base["baseline_run_ids"] = baseline_run_ids + base["compare_url"] = ext.build_compare_url( + self.result_run_id, baseline_run_ids + ) + if self.error: + base["error"] = self.error + return base + + def decisions(self) -> List[Dict[str, Any]]: + try: + return db.get_decisions(self.result_run_id) + except Exception: + return [] + + def result(self) -> Optional[Dict[str, Any]]: + base = ext.get_run_result(self.result_run_id, self.session_id) + if base is None: + return None + base["manifest"] = db.get_run_manifest(self.run_id) + return base diff --git a/dashboard/backend/execution/base.py b/dashboard/backend/execution/base.py index ae9854b..c0836c7 100644 --- a/dashboard/backend/execution/base.py +++ b/dashboard/backend/execution/base.py @@ -5,6 +5,12 @@ from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional +# A run's terminal statuses — no longer active, cannot advance. Single source +# of truth shared by every liveness/cap/archival check so a new status (e.g. a +# distinct "cancelled") can't silently drift between the surfaces that gate on +# it (BacktestBackend.is_active, ArchivedBacktestBackend, the v2 reaper). +TERMINAL_STATUSES = ("completed", "failed", "closed") + class ExecutionBackend(ABC): """One run's execution. `loop` advertises lifecycle parity: lockstep | realtime.""" diff --git a/dashboard/backend/infrastructure/llm/validator.py b/dashboard/backend/infrastructure/llm/validator.py index 55605cf..86cc746 100644 --- a/dashboard/backend/infrastructure/llm/validator.py +++ b/dashboard/backend/infrastructure/llm/validator.py @@ -592,6 +592,10 @@ def log_audit_trail( - HOLD confidence should usually be 0.30 to 0.60. - Use confidence above 0.85 only for very clear setups. +Constraints (fixed): +- Use ONLY the provided market_snapshot. No internet, tools, APIs, or code. +- Trade ONLY symbols listed in VALID SYMBOLS. + MARKET SNAPSHOT: {market_snapshot} diff --git a/dashboard/backend/infrastructure/market_data/alpaca_bars.py b/dashboard/backend/infrastructure/market_data/alpaca_bars.py index 6a0971f..110f157 100644 --- a/dashboard/backend/infrastructure/market_data/alpaca_bars.py +++ b/dashboard/backend/infrastructure/market_data/alpaca_bars.py @@ -1,11 +1,13 @@ """Alpaca historical bar loader. -Extracted (Phase 2B1) verbatim from ``AlpacaDataLoader`` in -``dashboard/scripts/backtest_hourly_agent.py``. Constructor signature, methods, -attributes, credentials-loading and precedence behavior, Alpaca client -construction, request parameters, timeframe/symbol handling, returned dataframe -columns/index/timezone/sorting, empty-response and exception behavior, and all -logging/warning output are unchanged. +Extracted (Phase 2B1) from ``AlpacaDataLoader`` in +``dashboard/scripts/backtest_hourly_agent.py``. One deliberate behavior change +since the move (B0/H4 deep fix): missing credentials or a missing alpaca-py SDK +raise :class:`MarketDataUnavailableError` instead of ``sys.exit(1)``. SystemExit +is a BaseException — it sailed past ``except Exception`` at every server call +site, silently killed daemon loader threads, and wedged the ASGI loop (the +original B0 hang). A plain exception is catchable everywhere; only CLI +entrypoints translate it back into an exit code. This is intentionally NOT merged with ``dashboard/backend/market_data.py``; that consolidation belongs to a later domain-migration phase. The Alpaca SDK imports @@ -22,7 +24,15 @@ from dashboard.backend.paths import CREDENTIALS_DIR -class AlpacaCredentialsError(RuntimeError): +class MarketDataUnavailableError(RuntimeError): + """Market data cannot be loaded (missing credentials, SDK, or data). + + Deliberately a plain Exception subclass: server code catches it with + ``except Exception``; CLI entrypoints convert it to ``sys.exit(1)``. + """ + + +class AlpacaCredentialsError(MarketDataUnavailableError): """Raised when Alpaca API credentials are not configured.""" @@ -32,22 +42,13 @@ class AlpacaDataLoader: def __init__(self, api_key: Optional[str] = None, secret_key: Optional[str] = None): """Initialize with Alpaca credentials.""" if not api_key or not secret_key: - try: - creds = self._load_credentials() - api_key = creds.get("api_key") - secret_key = creds.get("secret_key") - except AlpacaCredentialsError: - api_key = None - secret_key = None + creds = self._load_credentials() + api_key = creds.get("api_key") + secret_key = creds.get("secret_key") self.api_key = api_key self.secret_key = secret_key self.base_url = "https://data.alpaca.markets" - self.client = None - - if not self.api_key or not self.secret_key: - print("⚠️ Alpaca credentials not configured — bar fetch disabled") - return try: from alpaca.data.historical import StockHistoricalDataClient @@ -61,6 +62,9 @@ def __init__(self, api_key: Optional[str] = None, secret_key: Optional[str] = No except ImportError as e: print(f"❌ alpaca-py not installed: {e}") print(" Run: pip install alpaca-py") + raise MarketDataUnavailableError( + "alpaca-py is not installed (pip install alpaca-py)" + ) from e def _load_credentials(self) -> Dict: """Load Alpaca credentials from environment variables or file.""" @@ -77,7 +81,10 @@ def _load_credentials(self) -> Dict: if not creds_path.exists(): print(f"❌ Credentials not found in environment variables or file: {creds_path}") print(" Set ALPACA_API_KEY and ALPACA_SECRET_KEY environment variables") - raise AlpacaCredentialsError("Alpaca credentials not configured") + raise AlpacaCredentialsError( + "Alpaca credentials not found (set ALPACA_API_KEY and " + f"ALPACA_SECRET_KEY, or provide {creds_path})" + ) print(f"✅ Loaded Alpaca credentials from {creds_path}") with open(creds_path) as f: diff --git a/dashboard/backend/tests/_v2_fakes.py b/dashboard/backend/tests/_v2_fakes.py index 6d5784a..eea25c9 100644 --- a/dashboard/backend/tests/_v2_fakes.py +++ b/dashboard/backend/tests/_v2_fakes.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional from dashboard.backend.api.v2.models import SCHEMA_VERSION, UNIVERSE -from dashboard.backend.execution.base import ExecutionBackend +from dashboard.backend.execution.base import ExecutionBackend, TERMINAL_STATUSES class FakeBackend(ExecutionBackend): @@ -96,3 +96,8 @@ def decisions(self) -> List[Dict[str, Any]]: def cancel(self) -> None: self._status = "closed" + + def is_active(self) -> bool: + # Honest liveness (the ExecutionBackend default is always-True): the + # reaper sweep and cap reconcile rely on terminal fakes reporting done. + return self._status not in TERMINAL_STATUSES diff --git a/dashboard/backend/tests/domain/leaderboard/test_deploy_guard.py b/dashboard/backend/tests/domain/leaderboard/test_deploy_guard.py index 81a0fc2..c13d476 100644 --- a/dashboard/backend/tests/domain/leaderboard/test_deploy_guard.py +++ b/dashboard/backend/tests/domain/leaderboard/test_deploy_guard.py @@ -26,11 +26,26 @@ class FakeLLMStrategy: - """Mimics LLMAgentStrategy's reporting surface (exposes ``used_llm``).""" + """Mimics LLMAgentStrategy's reporting surface (exposes ``used_llm``). - def __init__(self, *, used_llm, llm_calls, model_id="test-model"): + ``decision_steps`` is the number of decision points in the run; when omitted + it defaults to ``llm_calls`` (i.e. 100% LLM coverage), so existing tests that + only care about the used_llm/llm_calls axis stay at full coverage. + + ``llm_decisions`` is how many steps produced a *usable* model decision (the + H6 coverage numerator); when omitted it defaults to ``llm_calls`` — the + common case where every billed call yielded a usable decision. + """ + + def __init__(self, *, used_llm, llm_calls, decision_steps=None, + llm_decisions=None, report_decisions=True, model_id="test-model"): self.used_llm = used_llm self.llm_calls = llm_calls + # An older strategy shape may not report llm_decisions at all; omit the + # attribute entirely so getattr on the guard side has to default it. + if report_decisions: + self.llm_decisions = llm_calls if llm_decisions is None else llm_decisions + self.decision_steps = llm_calls if decision_steps is None else decision_steps self.input_tokens = 10 self.output_tokens = 5 self.model_id = model_id @@ -99,6 +114,46 @@ def test_refuses_when_llm_calls_zero(guard_env, monkeypatch): canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) +def test_refuses_partial_llm_fallback(guard_env, monkeypatch): + # The client worked for one step then every other step fell back to + # rule-based: 1 of 161 decisions came from the model. Publishing this curve + # under the model's name would be ~99% rule-based. (This is the real Qwen + # 1-of-161 run that silently topped the board.) + _use(monkeypatch, FakeLLMStrategy(used_llm=True, llm_calls=1, decision_steps=161)) + with pytest.raises(canon_service.LeaderboardFallbackError): + canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) + run_id = canon_service._run_id("claude_haiku_4_5", "2026-04-15", "2026-05-15") + assert guard_env.get_run(run_id) is None # nothing persisted + + +def test_refuses_just_below_coverage_threshold(guard_env, monkeypatch): + # 94 of 100 steps LLM-decided = 94% < 95% threshold → refuse. + _use(monkeypatch, FakeLLMStrategy(used_llm=True, llm_calls=94, decision_steps=100)) + with pytest.raises(canon_service.LeaderboardFallbackError): + canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) + + +def test_publishes_at_coverage_threshold(guard_env, monkeypatch): + # 95 of 100 steps LLM-decided = exactly 95% → allowed (transient API blips + # on a genuine LLM run must not be misread as a fallback curve). + _use(monkeypatch, FakeLLMStrategy(used_llm=True, llm_calls=95, decision_steps=100)) + result = canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) + assert guard_env.get_run(result["run_id"]) is not None + + +def test_refuses_when_calls_succeed_but_no_usable_decisions(guard_env, monkeypatch): + # Every API call "succeeded" (llm_calls == decision_steps == 161) but the + # model's output was empty/unparseable almost every step, so only 1 step + # produced a usable decision. The curve is ~99% rule-based despite 100% call + # coverage — the guard must key off usable decisions, not billed calls. + _use(monkeypatch, FakeLLMStrategy( + used_llm=True, llm_calls=161, llm_decisions=1, decision_steps=161)) + with pytest.raises(canon_service.LeaderboardFallbackError): + canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) + run_id = canon_service._run_id("claude_haiku_4_5", "2026-04-15", "2026-05-15") + assert guard_env.get_run(run_id) is None # nothing persisted + + def test_allow_fallback_publishes(guard_env, monkeypatch): _use(monkeypatch, FakeLLMStrategy(used_llm=False, llm_calls=0)) result = canon_service.deploy_model_run( @@ -107,6 +162,28 @@ def test_allow_fallback_publishes(guard_env, monkeypatch): assert guard_env.get_run(result["run_id"]) is not None +def test_allow_fallback_publishes_partial_run(guard_env, monkeypatch): + # allow_fallback must bypass the *partial*-coverage guard too, not only the + # total-fallback case above — a deploy that explicitly opts in publishes a + # low-coverage run instead of being rejected. + _use(monkeypatch, FakeLLMStrategy( + used_llm=True, llm_calls=10, llm_decisions=10, decision_steps=161)) + result = canon_service.deploy_model_run( + "claude_haiku_4_5", force_refresh=True, allow_fallback=True + ) + assert guard_env.get_run(result["run_id"]) is not None + + +def test_strategy_without_llm_decisions_defaults_to_llm_calls(guard_env, monkeypatch): + # An older strategy that reports decision_steps but not llm_decisions must + # fall back to llm_calls coverage (the documented default), not be wrongly + # rejected as 0/decision_steps. + _use(monkeypatch, FakeLLMStrategy( + used_llm=True, llm_calls=100, decision_steps=100, report_decisions=False)) + result = canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) + assert guard_env.get_run(result["run_id"]) is not None + + def test_publishes_real_llm_run(guard_env, monkeypatch): _use(monkeypatch, FakeLLMStrategy(used_llm=True, llm_calls=5)) result = canon_service.deploy_model_run("claude_haiku_4_5", force_refresh=True) @@ -147,6 +224,36 @@ def test_ensure_leaderboard_runs_also_guards_llm_fallback(tmp_path, monkeypatch) canon_service.ensure_leaderboard_runs(force_refresh=True) +def test_ensure_leaderboard_runs_names_model_id_in_fallback(tmp_path, monkeypatch): + """The auto-compute guard path must name the offending model id in its + diagnostic (finding #3). Previously it omitted model_id and printed 'None', + hiding which gateway id failed.""" + cfg = { + "session_id": "lb-auto-test", + "start_date": "2026-04-15", + "end_date": "2026-05-15", + "initial_capital": 100000, + "strategies": [ + {"id": "sneaky_llm", "name": "Sneaky", "model": "Sneaky", + "strategy": "llm_agent", "auto_compute": True}, + ], + } + test_db = BacktestDatabase(db_path=tmp_path / "lb.db") + monkeypatch.setattr(canon_service, "db", test_db) + monkeypatch.setattr(canon_service, "load_leaderboard_config", lambda: dict(cfg)) + monkeypatch.setattr(canon_service, "get_strategy", lambda entry: FakeLLMStrategy( + used_llm=True, llm_calls=1, llm_decisions=1, decision_steps=161, + model_id="sneaky-gateway-id")) + monkeypatch.setattr(canon_service, "fetch_hourly_bars", lambda syms, s, e: {"AAPL": object()}) + monkeypatch.setattr(canon_service, "calc_metrics", lambda curve, cap: { + "initial_equity": cap, "final_equity": cap, "total_return": 0.0, + "sharpe_ratio": 0.0, "max_drawdown": 0.0, + }) + with pytest.raises(canon_service.LeaderboardFallbackError) as exc: + canon_service.ensure_leaderboard_runs(force_refresh=True) + assert "sneaky-gateway-id" in str(exc.value) + + def test_default_model_name_is_gateway_aware(monkeypatch): """The gateway-aware default llm_agent.py now uses: native id without a CommonStack key, the CommonStack slug with one.""" diff --git a/dashboard/backend/tests/domain/runs/test_repository_move.py b/dashboard/backend/tests/domain/runs/test_repository_move.py index ad2e89a..e09eb1b 100644 --- a/dashboard/backend/tests/domain/runs/test_repository_move.py +++ b/dashboard/backend/tests/domain/runs/test_repository_move.py @@ -18,7 +18,7 @@ _RUN_KEYS = { "run_id", "agent_id", "agent_version_id", "session_id", "environment_id", "environment_type", "config", "backtest_id", "result_run_id", "status", - "created_at", "updated_at", + "step_index", "total_steps", "created_at", "updated_at", } diff --git a/dashboard/backend/tests/infrastructure/market_data/test_alpaca_bars.py b/dashboard/backend/tests/infrastructure/market_data/test_alpaca_bars.py index 347090d..1e39eaa 100644 --- a/dashboard/backend/tests/infrastructure/market_data/test_alpaca_bars.py +++ b/dashboard/backend/tests/infrastructure/market_data/test_alpaca_bars.py @@ -124,14 +124,21 @@ def test_credentials_from_file_fallback(fake_alpaca, monkeypatch, tmp_path): assert loader.secret_key == "file-s" -def test_missing_credentials_exits(fake_alpaca, monkeypatch, tmp_path): +def test_missing_credentials_raises(fake_alpaca, monkeypatch, tmp_path): + """Missing credentials raise MarketDataUnavailableError — deliberately NOT + SystemExit (B0 deep fix): a plain exception is catchable by the server's + `except Exception` boundaries. See tests/test_market_data_errors.py.""" + from dashboard.backend.infrastructure.market_data.alpaca_bars import ( + MarketDataUnavailableError, + ) + monkeypatch.delenv("ALPACA_API_KEY", raising=False) monkeypatch.delenv("ALPACA_SECRET_KEY", raising=False) monkeypatch.setattr( "dashboard.backend.infrastructure.market_data.alpaca_bars.CREDENTIALS_DIR", tmp_path, # no alpaca.json here ) - with pytest.raises(SystemExit): + with pytest.raises(MarketDataUnavailableError): AlpacaDataLoader() diff --git a/dashboard/backend/tests/llm/test_backtest_harness.py b/dashboard/backend/tests/llm/test_backtest_harness.py index cd1591a..a4aefa0 100644 --- a/dashboard/backend/tests/llm/test_backtest_harness.py +++ b/dashboard/backend/tests/llm/test_backtest_harness.py @@ -237,6 +237,95 @@ def test_empty_actions_list_falls_back_to_rule_based(): assert pm.llm_calls == 1 +# --------------------------------------------------------------------------- +# llm_decisions: the H6 coverage numerator (steps the model actually drove). +# Distinct from llm_calls (billed API calls): a call whose response is empty or +# unparseable is billed but produced no usable decision, so it must NOT count +# toward model coverage — else a run that returns garbage every step would show +# 100% coverage and slip past the H6 integrity guard while trading rule-based. +# --------------------------------------------------------------------------- + +def test_fresh_manager_has_zero_llm_decisions(): + assert bha.PortfolioManager(100000).llm_decisions == 0 + + +def test_usable_decision_counts_as_llm_decision(): + pm = bha.PortfolioManager(100000) + resp_text = json.dumps({"actions": [ + {"symbol": "AAPL", "action": "buy", "confidence": 0.9, + "reasoning": "strong", "position_size": 10}, + ]}) + client = _FakeClient(_FakeResponse(resp_text, usage=_FakeUsage(100, 50))) + pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert pm.llm_calls == 1 + assert pm.llm_decisions == 1 + + +def test_malformed_response_is_billed_but_not_a_decision(): + # Unparseable output (e.g. JSON truncated by an output-token cap): billed, + # but no usable model decision → counts for cost, not for coverage. + pm = bha.PortfolioManager(100000) + client = _FakeClient(_FakeResponse("totally not json", usage=_FakeUsage(10, 5))) + pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert pm.llm_calls == 1 + assert pm.llm_decisions == 0 + + +def test_empty_actions_is_billed_but_not_a_decision(): + # An empty actions list explicitly falls back to rule-based, so that step is + # rule-based-driven and must not count toward model coverage. + pm = bha.PortfolioManager(100000) + client = _FakeClient(_FakeResponse('{"actions": []}', usage=_FakeUsage(7, 3))) + pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert pm.llm_calls == 1 + assert pm.llm_decisions == 0 + + +def test_filtered_actions_still_count_as_a_decision(): + # The model produced a non-empty decision that our confidence policy then + # filtered out. The model still drove the step, so it counts toward coverage. + pm = bha.PortfolioManager(100000) + resp_text = json.dumps({"actions": [ + {"symbol": "AAPL", "action": "buy", "confidence": 0.1, "reasoning": "meh"}, + ]}) + client = _FakeClient(_FakeResponse(resp_text, usage=_FakeUsage(1, 1))) + pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert pm.llm_calls == 1 + assert pm.llm_decisions == 1 + + +def test_post_parse_exception_is_billed_but_not_a_decision(): + # Valid JSON with a non-empty actions list, but a field has the wrong type + # (confidence as a string "0.9"), so the per-action processing loop raises. + # The blanket except converts the whole step to a pure rule-based fallback — + # the returned actions are the rule-based engine's, not the model's — so it + # must NOT count toward model coverage even though parsing "succeeded". + # (Otherwise a model that reliably emits subtly-malformed actions would show + # 100% coverage while trading fully rule-based, defeating the H6 guard.) + pm = bha.PortfolioManager(100000) + resp_text = json.dumps({"actions": [ + {"symbol": "AAPL", "action": "buy", "confidence": "0.9", + "reasoning": "strong", "position_size": 10}, + ]}) + client = _FakeClient(_FakeResponse(resp_text, usage=_FakeUsage(10, 5))) + out = pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert out == pm.make_trading_decision(_portfolio_state()) # rule-based result + assert pm.llm_calls == 1 # billed + assert pm.llm_decisions == 0 # but not a usable model decision + + +def test_malformed_action_item_is_billed_but_not_a_decision(): + # A non-empty actions list whose items are the wrong shape (strings, not + # dicts) also throws inside the processing loop → rule-based fallback. + pm = bha.PortfolioManager(100000) + client = _FakeClient(_FakeResponse('{"actions": ["buy AAPL now"]}', + usage=_FakeUsage(10, 5))) + out = pm.make_trading_decision_with_llm(_portfolio_state(), client) + assert out == pm.make_trading_decision(_portfolio_state()) + assert pm.llm_calls == 1 + assert pm.llm_decisions == 0 + + # --------------------------------------------------------------------------- # Legacy method: successful BUY / SELL conversion + token accounting # --------------------------------------------------------------------------- diff --git a/dashboard/backend/tests/test_agent_runs_metadata.py b/dashboard/backend/tests/test_agent_runs_metadata.py new file mode 100644 index 0000000..f86ad38 --- /dev/null +++ b/dashboard/backend/tests/test_agent_runs_metadata.py @@ -0,0 +1,125 @@ +"""agent_runs.metadata column + effective LLM_MAX_OUTPUT_TOKENS recording. + +LOW-sweep residual (PR #67): the per-request output-token ceiling is an env +knob (LLM_MAX_OUTPUT_TOKENS) that changes a run's spend and behavior, but no +row recorded which value was in effect. agent_runs gains a JSON metadata +column (config snapshot, additive) and the engine's LLM-driven agent run +records its effective cap there. +""" + +import sqlite3 + +import pytest + +from dashboard.backend.database import BacktestDatabase + + +def _make_db(tmp_path, name="meta.db"): + return BacktestDatabase(tmp_path / name) + + +def _insert_minimal(db, run_id, metadata=None): + db.insert_run( + run_id=run_id, + session_id="meta-session", + agent_name="meta-agent", + mode="backtest", + start_date="2026-01-01", + end_date="2026-01-31", + initial_equity=100000.0, + metadata=metadata, + ) + + +def test_insert_run_roundtrips_metadata(tmp_path): + db = _make_db(tmp_path) + _insert_minimal(db, "run_meta_1", metadata={"llm_max_output_tokens": 600}) + run = db.get_run("run_meta_1") + assert run["metadata"] == {"llm_max_output_tokens": 600} + + +def test_metadata_defaults_to_none(tmp_path): + db = _make_db(tmp_path) + _insert_minimal(db, "run_meta_2") + assert db.get_run("run_meta_2")["metadata"] is None + + +def test_session_listings_parse_metadata_consistently(tmp_path): + """SELECT * picks the new column up in the list readers too — they must + return the same parsed shape as get_run, not raw JSON text.""" + db = _make_db(tmp_path) + _insert_minimal(db, "run_meta_3", metadata={"llm_max_output_tokens": 1234}) + by_session = db.get_runs_by_session("meta-session") + assert by_session[0]["metadata"] == {"llm_max_output_tokens": 1234} + grouped = db.get_runs_by_sessions(["meta-session"]) + assert grouped["meta-session"][0]["metadata"] == {"llm_max_output_tokens": 1234} + + +def test_migration_adds_metadata_column(tmp_path): + """A DB created before the column must gain it on open (both + _init_schema's CREATE IF NOT EXISTS and _migrate_schema must know it).""" + path = tmp_path / "old.db" + conn = sqlite3.connect(str(path)) + conn.execute( + """ + CREATE TABLE agent_runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + mode TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + initial_equity REAL NOT NULL, + final_equity REAL, + total_return REAL, + sharpe_ratio REAL, + max_drawdown REAL, + num_trades INTEGER DEFAULT 0, + llm_calls INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + est_cost_usd REAL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.commit() + conn.close() + + db = BacktestDatabase(path) + conn = sqlite3.connect(str(path)) + cols = {row[1] for row in conn.execute("PRAGMA table_info(agent_runs)")} + conn.close() + assert "metadata" in cols + _insert_minimal(db, "run_meta_migrated", metadata={"llm_max_output_tokens": 2000}) + assert db.get_run("run_meta_migrated")["metadata"] == { + "llm_max_output_tokens": 2000 + } + + +def test_engine_llm_run_metadata_snapshot(monkeypatch): + """The engine's agent run records the EFFECTIVE cap (whatever the env + parse produced), and rule-based runs record nothing.""" + import dashboard.backend.domain.backtesting.engine as engine_mod + from dashboard.backend.domain.backtesting.engine import HourlyBacktester + + backtester = HourlyBacktester.__new__(HourlyBacktester) # skip creds init + monkeypatch.setattr(engine_mod.llm_harness, "DEFAULT_MAX_OUTPUT_TOKENS", 777) + + backtester.use_llm = True + assert backtester._llm_run_metadata() == {"llm_max_output_tokens": 777} + backtester.use_llm = False + assert backtester._llm_run_metadata() is None + + +def test_engine_agent_run_wires_the_metadata(): + """Wiring guard: the agent-run insert (the LLM one, not the baselines) + passes the metadata snapshot.""" + from pathlib import Path + + engine_src = ( + Path(__file__).resolve().parents[1] + / "domain" / "backtesting" / "engine.py" + ).read_text(encoding="utf-8") + assert "metadata=self._llm_run_metadata()" in engine_src diff --git a/dashboard/backend/tests/test_backtest_isolation.py b/dashboard/backend/tests/test_backtest_isolation.py index 172a885..bc51c87 100644 --- a/dashboard/backend/tests/test_backtest_isolation.py +++ b/dashboard/backend/tests/test_backtest_isolation.py @@ -26,24 +26,32 @@ def temp_db(): @pytest.fixture def client(temp_db, monkeypatch): """Test client with temporary database.""" - # Patch the database module to use temp DB + # Patch the database module to use temp DB. The backtests router binds + # `db` at import time (`from ... import db`), so its module attribute + # must be patched too or its routes silently keep using the global DB. import dashboard.backend.app as app_module import dashboard.backend.database as db_module - + import dashboard.backend.api.routers.backtests as backtests_module + monkeypatch.setattr(app_module, "db", temp_db) monkeypatch.setattr(db_module, "db", temp_db) - + monkeypatch.setattr(backtests_module, "db", temp_db) + return TestClient(app) -def test_session_isolation_backtest_list(client, temp_db): - """Session B should not see Session A's backtests.""" - +def test_runs_listing_is_public_by_design(client, temp_db): + """GET /runs is a PUBLIC listing (run metadata only): the route docstring + states backtest results are meant to be shared/viewed, and the dashboard + home page lists the seed runs without any session header. Per-run DATA + access stays session-scoped (see test_session_cannot_access_other_backtest + and the /equity route). This replaces an older test that pinned a strict + per-session listing the product deliberately does not have.""" + session_a = str(uuid.uuid4()) session_b = str(uuid.uuid4()) run_a = str(uuid.uuid4()) run_b = str(uuid.uuid4()) - - # Create backtest in Session A + temp_db.insert_run( run_id=run_a, session_id=session_a, @@ -53,8 +61,6 @@ def test_session_isolation_backtest_list(client, temp_db): end_date="2024-01-31", initial_equity=100000 ) - - # Create backtest in Session B temp_db.insert_run( run_id=run_b, session_id=session_b, @@ -64,22 +70,13 @@ def test_session_isolation_backtest_list(client, temp_db): end_date="2024-01-31", initial_equity=100000 ) - - # Session A lists backtests: should only see its own - response_a = client.get('/runs', headers={'X-Session-Id': session_a}) - assert response_a.status_code == 200 - runs_a = response_a.json() - assert len(runs_a) == 1 - assert runs_a[0]['agent_name'] == "Agent A" - - # Session B lists backtests: should only see its own - response_b = client.get('/runs', headers={'X-Session-Id': session_b}) - assert response_b.status_code == 200 - runs_b = response_b.json() - assert len(runs_b) == 1 - assert runs_b[0]['agent_name'] == "Agent B" - - print("✅ Session isolation: Each session sees only its own backtests") + + # The listing is the same for everyone — with or without a session header. + for headers in ({}, {'X-Session-Id': session_a}): + response = client.get('/runs', headers=headers) + assert response.status_code == 200 + names = {r['agent_name'] for r in response.json()} + assert {"Agent A", "Agent B"} <= names def test_session_cannot_access_other_backtest(client, temp_db): """Session A should get 404 when accessing Session B's run_id.""" @@ -116,11 +113,14 @@ def test_latest_run_is_session_specific(client, temp_db): run_id_a2 = str(uuid.uuid4()) run_id_b = str(uuid.uuid4()) + # /runs/latest/metrics only considers the internal hourly agent's runs + # (agent_name == 'Agent'); the old fixture's "Agent A"/"Agent B" names + # never matched the route's filter and 404'd. # Create 2 backtests in Session A (with 1+ second delay to ensure different timestamps) temp_db.insert_run( run_id=run_id_a1, session_id=session_a, - agent_name="Agent A", + agent_name="Agent", mode="backtest", start_date="2024-01-01", end_date="2024-01-31", @@ -130,18 +130,18 @@ def test_latest_run_is_session_specific(client, temp_db): temp_db.insert_run( run_id=run_id_a2, session_id=session_a, - agent_name="Agent A", + agent_name="Agent", mode="backtest", start_date="2024-02-01", end_date="2024-02-28", initial_equity=100000 ) - + # Create 1 backtest in Session B temp_db.insert_run( run_id=run_id_b, session_id=session_b, - agent_name="Agent B", + agent_name="Agent", mode="backtest", start_date="2024-01-01", end_date="2024-01-31", @@ -158,15 +158,30 @@ def test_latest_run_is_session_specific(client, temp_db): assert response_b.status_code == 200 assert response_b.json()['run_id'] == run_id_b -def test_missing_session_header_rejected(client): - """Backtest endpoints should reject missing X-Session-Id.""" +def test_public_listing_needs_no_session_header(client): + """GET /runs (public listing) must work without X-Session-Id — the + dashboard home page fetches it before any session exists. (Replaces an + older test that expected a 400 here; see + test_runs_listing_is_public_by_design.)""" response = client.get('/runs') - assert response.status_code == 400 - assert 'Missing X-Session-Id' in str(response.json()) + assert response.status_code == 200 + assert isinstance(response.json(), list) -def test_invalid_session_id_rejected(client): - """Invalid session ID should be rejected.""" - response = client.get('/runs', headers={'X-Session-Id': 'not-a-uuid'}) +def test_invalid_session_id_fails_closed_on_scoped_routes(client, temp_db): + """A malformed session id must never unlock another session's run: the + session middleware rejects non-UUID ids with 400 on session-scoped + routes (the public /runs listing and /paper/* are exempt).""" + run_id = str(uuid.uuid4()) + temp_db.insert_run( + run_id=run_id, + session_id=str(uuid.uuid4()), + agent_name="Agent", + mode="backtest", + start_date="2024-01-01", + end_date="2024-01-31", + initial_equity=100000 + ) + response = client.get(f'/runs/{run_id}', headers={'X-Session-Id': 'not-a-uuid'}) assert response.status_code == 400 assert 'Invalid' in str(response.json()) diff --git a/dashboard/backend/tests/test_llm_validator.py b/dashboard/backend/tests/test_llm_validator.py index b2bc5bb..aacf301 100644 --- a/dashboard/backend/tests/test_llm_validator.py +++ b/dashboard/backend/tests/test_llm_validator.py @@ -381,13 +381,18 @@ class TestSafePromptGeneration: """Test that safe prompts are generated correctly""" def test_prompt_contains_constraints(self): - """Prompt should contain security constraints""" + """Prompt should contain the no-tools + JSON-only constraints. + + The wording tracks the current active prompt (the strategy body was + rewritten; the old CANNOT-phrased prompt is retired). These lines are + defense-in-depth only — the hard boundary is response-side + enforcement in validate_llm_response (tool_use rejected, JSON-only), + pinned by the TestToolCallPrevention tests.""" snapshot = {"test": "data"} prompt = create_safe_prompt(snapshot) - - assert "CANNOT access the internet" in prompt - assert "CANNOT use tools" in prompt - assert "CANNOT attempt tool_use" in prompt + + assert "No internet, tools, APIs, or code" in prompt + assert "Use ONLY the provided market_snapshot" in prompt assert "ONLY valid JSON" in prompt def test_prompt_contains_schema(self): diff --git a/dashboard/backend/tests/test_market_data_errors.py b/dashboard/backend/tests/test_market_data_errors.py new file mode 100644 index 0000000..c97709a --- /dev/null +++ b/dashboard/backend/tests/test_market_data_errors.py @@ -0,0 +1,74 @@ +"""Deep fix for the sys.exit()-in-library-code class (B0/H4 follow-up). + +AlpacaDataLoader, BaselineGenerator and the engine used to sys.exit(1) on +missing credentials / missing SDK / empty data. SystemExit is a BaseException: +it sails past `except Exception`, silently kills daemon threads, and wedged +the ASGI loop (the original B0 hang). The B0/H4 fixes added +`except (Exception, SystemExit)` guards at every known call site — this is the +class fix: the libraries raise MarketDataUnavailableError (a plain Exception) +and only the CLI entrypoints translate it to an exit code. +""" + +import pytest + +import dashboard.backend.baseline_generator as bg_mod +import dashboard.backend.infrastructure.market_data.alpaca_bars as bars_mod +from dashboard.backend.baseline_generator import BaselineGenerator +from dashboard.backend.infrastructure.market_data.alpaca_bars import ( + AlpacaDataLoader, + MarketDataUnavailableError, +) + + +def _clear_creds(monkeypatch, tmp_path, mod): + monkeypatch.delenv("ALPACA_API_KEY", raising=False) + monkeypatch.delenv("ALPACA_SECRET_KEY", raising=False) + # Point the file fallback at an empty directory so a developer's local + # credentials/alpaca.json can't satisfy the lookup. + monkeypatch.setattr(mod, "CREDENTIALS_DIR", tmp_path) + + +def test_market_data_error_is_a_plain_exception(): + """The whole point of the class fix: `except Exception` at server + boundaries must catch it (SystemExit never was).""" + assert issubclass(MarketDataUnavailableError, Exception) + assert not issubclass(MarketDataUnavailableError, SystemExit) + + +def test_alpaca_loader_missing_credentials_raises_not_exits(monkeypatch, tmp_path): + _clear_creds(monkeypatch, tmp_path, bars_mod) + with pytest.raises(MarketDataUnavailableError): + AlpacaDataLoader() + + +def test_baseline_generator_missing_credentials_raises_not_exits(monkeypatch, tmp_path): + _clear_creds(monkeypatch, tmp_path, bg_mod) + with pytest.raises(MarketDataUnavailableError): + BaselineGenerator() + + +def test_engine_load_data_empty_raises_not_exits(): + from dashboard.backend.domain.backtesting.engine import HourlyBacktester + + backtester = HourlyBacktester.__new__(HourlyBacktester) # skip creds init + backtester.data_loader = type( + "EmptyLoader", (), {"fetch_bars": lambda self, *a, **k: {}} + )() + backtester.start_date = "2026-01-01" + backtester.end_date = "2026-01-02" + with pytest.raises(MarketDataUnavailableError): + backtester.load_data() + + +def test_cli_entrypoints_translate_the_error_to_an_exit_code(): + """The CLI boundary is where process exit belongs: both backtest scripts' + __main__ blocks must catch MarketDataUnavailableError and sys.exit(1) + (source-level guard, same style as tests/integrations/).""" + from pathlib import Path + + scripts_dir = Path(__file__).resolve().parents[2] / "scripts" + for name in ("backtest_hourly_agent.py", "backtest_custom_algo.py"): + source = (scripts_dir / name).read_text(encoding="utf-8") + assert "MarketDataUnavailableError" in source, ( + f"{name} must translate MarketDataUnavailableError to an exit code" + ) diff --git a/dashboard/backend/tests/test_pr71_review_fixes.py b/dashboard/backend/tests/test_pr71_review_fixes.py new file mode 100644 index 0000000..0b411e1 --- /dev/null +++ b/dashboard/backend/tests/test_pr71_review_fixes.py @@ -0,0 +1,262 @@ +"""Regression tests for the PR #71 review findings. + +Each test pins a defect surfaced by the review and is red before its fix: + +1. cancel-during-loading race — load_market_data / background _load must not + resurrect a run a concurrent cancel() moved to "closed". +2. archived status() must keep baseline_run_ids + compare_url for a completed + run (the live get_status returns them; the tombstone dropped them). +3. a rehydrated failed/closed run must report its real step counts, not 0/0. +4. finalize must publish "completed" before the slow best-effort baseline + generation, so cancel()/is_active() (lock-free reads) don't block on it. +5. the v2 _runs registry must be bounded by ACTIVE runs — the reaper evicts + archived tombstones (a later read rehydrates from the terminal row). +""" + +import threading +import uuid + +import pytest +from fastapi.testclient import TestClient + +import dashboard.backend.api.v2.runs as runs_mod +import dashboard.backend.domain.backtesting.external_run_service as svc +import dashboard.backend.domain.runs.repository as run_store_module +from dashboard.backend.app import app +from dashboard.backend.database import BacktestDatabase +from dashboard.backend.execution.backtest_backend import ( + ArchivedBacktestBackend, + BacktestBackend, +) +from dashboard.backend.tests._v2_fakes import FakeBackend + +client = TestClient(app) +run_store = run_store_module.run_store + + +def _agent(name): + r = client.post("/api/v2/agents", json={"name": name}).json() + return r["api_key"], r["session_id"], r["agent_id"] + + +# --------------------------------------------------------------------------- +# 1. cancel-during-loading race +# --------------------------------------------------------------------------- + + +def test_load_market_data_does_not_resurrect_a_cancelled_run(monkeypatch): + """A cancel() that lands while the background fetch is in flight writes + 'closed' under _step_lock. load_market_data's publish must respect that + terminal state instead of overwriting it back to waiting_decision.""" + session = svc.ExternalBacktestSession( + backtest_id="bt_cancel_load", session_id="s", agent_name="a", + model_name="m", start_date="2026-04-15", end_date="2026-04-16", + ) + + class _Loader: + def fetch_bars(self, symbols, start, end): + return {"AAA": object()} + + monkeypatch.setattr(svc, "AlpacaDataLoader", _Loader) + monkeypatch.setattr( + svc.TechnicalIndicators, "calculate_indicators", + staticmethod(lambda df: df), + ) + monkeypatch.setattr(session, "_build_trading_timestamps", lambda: ["ts0"]) + monkeypatch.setattr(session, "_build_price_cache", lambda: {}) + + # The agent cancelled while the fetch was running. + session.status = "closed" + session.load_market_data() + + assert session.status == "closed", "load must not resurrect a cancelled run" + + +def test_background_load_does_not_revive_a_cancelled_row(monkeypatch): + """The backend's _load success path must not stamp the row 'running' when + a cancel() won during loading (the session's fixed guard left it terminal). + Without the guard, _load unconditionally revived the row to 'running'.""" + run_id = f"run_loadrace_{uuid.uuid4().hex[:6]}" + run_store.create_run( + run_id=run_id, agent_id="ag_lr", agent_version_id=None, + session_id="sess_lr", environment_id=None, environment_type="backtest", + config={}, backtest_id=None, status="loading", + ) + + done = threading.Event() + + class _CancelledDuringLoad: + def __init__(self): + self._step_lock = threading.Lock() + self.status = "loading" + self.error = None + + def load_market_data(self): + # A cancel() landed mid-load; load_market_data's terminal guard + # (the real fix) leaves the run "closed" instead of publishing + # waiting_decision. + self.status = "closed" + done.set() + + backend = BacktestBackend.__new__(BacktestBackend) + backend.run_id = run_id + backend.session = _CancelledDuringLoad() + backend.start_background_load() + + assert done.wait(timeout=3) + threading.Event().wait(0.2) # let _load finish its post-load row write + + assert run_store.get_run(run_id)["status"] != "running", ( + "a cancelled run's row must not be revived to 'running' by the loader" + ) + + +# --------------------------------------------------------------------------- +# 2. archived status() keeps baseline_run_ids + compare_url +# --------------------------------------------------------------------------- + + +def test_archived_status_preserves_baseline_run_ids_and_compare_url(tmp_path, monkeypatch): + """Once archived, a completed run's status() must still carry the same + baseline_run_ids + compare_url the live get_status() returns.""" + tdb = BacktestDatabase(tmp_path / "arch.db") + monkeypatch.setattr("dashboard.backend.execution.backtest_backend.db", tdb) + + result_run_id = "run_arch_complete" + tdb.insert_run( + run_id=result_run_id, session_id="s", agent_name="a", mode="backtest", + start_date="2026-04-15", end_date="2026-04-16", initial_equity=100000.0, + final_equity=101000.0, total_return=0.01, + ) + tdb.update_run_baselines( + result_run_id, djia_run_id="run_djia_x", buyhold_run_id="run_bh_x", + ) + + archived = ArchivedBacktestBackend( + run_id=result_run_id, session_id="s", status="completed", + result_run_id=result_run_id, step_index=3, total_steps=3, + ) + body = archived.status() + + assert body["baseline_run_ids"] == { + "buy_and_hold": "run_bh_x", "djia": "run_djia_x", + } + # Same ordering as the live _compare_url: run_id, djia, buy_and_hold. + assert body["compare_url"] == ( + f"/compare?run_ids={result_run_id},run_djia_x,run_bh_x" + ) + + +# --------------------------------------------------------------------------- +# 3. rehydrated failed/closed run reports real step counts +# --------------------------------------------------------------------------- + + +def test_archive_persists_step_counts_for_failed_run(monkeypatch): + """_archive_run must persist step_index/total_steps so a post-restart + from_record recovers real progress for a FAILED run (not a bogus 0/0 — + a failed run has no decision-log rows to recover from).""" + run_id = f"run_failsteps_{uuid.uuid4().hex[:6]}" + run_store.create_run( + run_id=run_id, agent_id="ag_fs", agent_version_id=None, + session_id="sess_fs", environment_id=None, environment_type="backtest", + config={}, backtest_id=None, status="running", + ) + fake = FakeBackend(run_id=run_id, total_steps=5, session_id="sess_fs") + fake.step_index = 2 + fake._status = "failed" + runs_mod.register_run(run_id, fake, "sess_fs", "ag_fs") + + runs_mod._archive_run(run_id, runs_mod._runs[run_id], fake) + + record = run_store.get_run(run_id) + assert record["step_index"] == 2 + assert record["total_steps"] == 5 + # Post-restart rehydration path: no live session, DB row only. + rehydrated = ArchivedBacktestBackend.from_record(record) + assert rehydrated.step_index == 2 + assert rehydrated.total_steps == 5 + + +# --------------------------------------------------------------------------- +# 4. finalize publishes "completed" before slow baseline generation +# --------------------------------------------------------------------------- + + +def test_finalize_marks_completed_before_baselines(monkeypatch, tmp_path): + """cancel()/is_active() read session.status without _step_lock. If status + only flips to 'completed' AFTER baseline generation (seconds-to-minutes + under the lock), those reads are wrong/blocked. Status must be set first.""" + tdb = BacktestDatabase(tmp_path / "fin.db") + monkeypatch.setattr(svc, "db", tdb) + + started = threading.Event() + release = threading.Event() + + class _BlockingBacktester: + def __init__(self, *a, **k): + self.all_data = {} + + def run_buyhold_baseline(self): + started.set() + release.wait(timeout=5) + return (None, None) + + def run_djia_baseline(self): + return (None, None) + + monkeypatch.setattr(svc, "HourlyBacktester", _BlockingBacktester) + + session = svc.ExternalBacktestSession( + backtest_id="bt_fin", session_id="s", agent_name="a", model_name="m", + start_date="2026-04-15", end_date="2026-04-16", + ) + session.run_id = f"run_fin_{uuid.uuid4().hex[:6]}" + session.total_steps = 1 + session.step_index = 1 + + t = threading.Thread(target=session._finalize, daemon=True) + t.start() + try: + assert started.wait(timeout=3), "baseline generation never started" + assert session.status == "completed", ( + "status must be 'completed' before the slow baseline block runs" + ) + finally: + release.set() + t.join(timeout=5) + assert session.status == "completed" + + +# --------------------------------------------------------------------------- +# 5. the v2 _runs registry is bounded (tombstones are evicted) +# --------------------------------------------------------------------------- + + +def test_reaper_evicts_archived_tombstones(monkeypatch): + """A tombstone archived in a prior pass must be evicted from _runs on the + next pass (a later read rehydrates it from the terminal row), so _runs is + bounded by ACTIVE runs, not total historical runs.""" + key, sid, agent_id = _agent("prune-owner") + run_id = f"run_prune_{uuid.uuid4().hex[:6]}" + fake = FakeBackend(run_id=run_id, total_steps=1, session_id=sid) + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + runs_mod.register_run(run_id, fake, sid, agent_id) + fake.apply_decisions([]) # completes the fake + + runs_mod.reap_v2_runs() # pass 1: archives -> tombstone kept in _runs + with runs_mod._lock: + assert isinstance(runs_mod._runs[run_id]["backend"], ArchivedBacktestBackend) + + runs_mod.reap_v2_runs() # pass 2: evicts the prior-pass tombstone + with runs_mod._lock: + assert run_id not in runs_mod._runs, "tombstone must be evicted" + + # Reads still work — the terminal row rehydrates on demand. + resp = client.get(f"/api/v2/runs/{run_id}", headers={"X-API-Key": key}) + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" diff --git a/dashboard/backend/tests/test_run_lifecycle_unification.py b/dashboard/backend/tests/test_run_lifecycle_unification.py new file mode 100644 index 0000000..5c38044 --- /dev/null +++ b/dashboard/backend/tests/test_run_lifecycle_unification.py @@ -0,0 +1,688 @@ +"""Run-lifecycle unification across /api/v1 and /api/v2 (PR #67 H4 follow-ups). + +Three related contracts, one mechanism — v2 runs persist protocol_runs rows +exactly like v1 runs do: + +1. Unified per-agent active-run cap: run_store.count_active_runs() covers both + surfaces, and both create paths serialize on the same lock, so an agent can + no longer hold 2× MAX_ACTIVE_RUNS_PER_AGENT by splitting across v1 and v2. +2. Reaper/orphan-recovery registration for v2: the v1 reaper drives a + registered v2 sweep (drain deadlines, archive terminal backends); startup + recovery marks crashed v2 rows failed, and the v2 API rehydrates terminal + rows so reads survive both archival and restart. +3. Multi-worker recovery hardening: rows carry owner_instance + heartbeat_at; + live runs are heartbeated by the reaper, and only stale rows are failed — + a second worker's live runs are no longer collateral damage. +""" + +import sqlite3 +import time +import uuid + +import pytest +from fastapi.testclient import TestClient + +import dashboard.backend.api.v2.runs as runs_mod +import dashboard.backend.domain.runs.repository as run_store_module +import dashboard.backend.domain.runs.service as run_service +from dashboard.backend.app import app +from dashboard.backend.database import db +from dashboard.backend.domain.runs.repository import RunStore +from dashboard.backend.execution.backtest_backend import ArchivedBacktestBackend +from dashboard.backend.tests._v2_fakes import FakeBackend + +client = TestClient(app) + +run_store = run_store_module.run_store + + +def _agent(name): + r = client.post("/api/v2/agents", json={"name": name}).json() + return r["api_key"], r["session_id"], r["agent_id"] + + +class _StubBackend: + """Create-path stub: no Alpaca, stays active until told otherwise.""" + + loop = "lockstep" + news_sentiment_source = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self._active = True + self._final = "completed" + + def start_background_load(self): + pass + + def is_active(self): + return self._active + + def status(self): + return {"status": "waiting_decision" if self._active else self._final} + + def advance(self): + pass + + def cancel(self): + self._active = False + self._final = "closed" + + +def _create_v2_run(key, monkeypatch, backend_cls=_StubBackend): + monkeypatch.setattr(runs_mod, "BacktestBackend", backend_cls) + resp = client.post( + "/api/v2/runs", + json={"start_date": "2026-04-15", "end_date": "2026-04-16"}, + headers={"X-API-Key": key}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["run_id"] + + +# --------------------------------------------------------------------------- +# 1. v2 runs persist protocol_runs rows through their lifecycle +# --------------------------------------------------------------------------- + + +def test_v2_create_persists_protocol_run_row(monkeypatch): + key, sid, agent_id = _agent("row-writer") + run_id = _create_v2_run(key, monkeypatch) + + record = run_store.get_run(run_id) + assert record is not None, "v2 create must write a protocol_runs row" + assert record["agent_id"] == agent_id + assert record["session_id"] == sid + assert record["status"] in ("created", "loading", "running") + + +def test_v2_cancel_marks_row_closed(monkeypatch): + key, _, _ = _agent("row-canceller") + run_id = _create_v2_run(key, monkeypatch) + + resp = client.post(f"/api/v2/runs/{run_id}/cancel", headers={"X-API-Key": key}) + assert resp.status_code == 200 + assert run_store.get_run(run_id)["status"] == "closed" + + +# --------------------------------------------------------------------------- +# 2. Unified active-run cap across surfaces +# --------------------------------------------------------------------------- + + +def test_v1_active_runs_count_against_v2_creates(monkeypatch): + """An agent at the cap on the v1 surface must be refused on v2.""" + key, sid, agent_id = _agent("cross-cap-v1v2") + monkeypatch.setattr(runs_mod, "MAX_ACTIVE_RUNS_PER_AGENT", 1) + # A v1-created run: row in protocol_runs, no v2 registry entry. + run_store.create_run( + agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id="us-equity-hourly-v1", environment_type="backtest", + config={}, backtest_id="bt_cross_v1", status="running", + ) + + monkeypatch.setattr(runs_mod, "BacktestBackend", _StubBackend) + resp = client.post( + "/api/v2/runs", + json={"start_date": "2026-04-15", "end_date": "2026-04-16"}, + headers={"X-API-Key": key}, + ) + assert resp.status_code == 429, resp.text + assert resp.json()["error"]["code"] == "too_many_active_runs" + + +def test_v2_active_runs_count_against_v1_creates(monkeypatch): + """The reverse hole: a v2-held run must count when v1 checks the cap.""" + key, sid, agent_id = _agent("cross-cap-v2v1") + monkeypatch.setattr(run_service, "MAX_ACTIVE_RUNS_PER_AGENT", 1) + _create_v2_run(key, monkeypatch) # leaves an active v2 run + its row + + with pytest.raises(run_service.ProtocolError) as ei: + run_service.create_run( + agent={"agent_id": agent_id, "session_id": sid, "name": "x"}, + agent_version=None, + environment_id="us-equity-hourly-v1", + config={"start_date": "2026-04-15", "end_date": "2026-04-16"}, + ) + assert ei.value.code == "too_many_active_runs" + + +def test_v2_cap_frees_terminal_runs_without_status_side_effects(monkeypatch): + """A v2 run that finished must stop counting even before the reaper's next + sweep — the create path reconciles its own registry's inactive backends. + Preserves the merge-hardening invariant (cap ≠ status() on live runs).""" + key, _, _ = _agent("cap-reconcile") + monkeypatch.setattr(runs_mod, "MAX_ACTIVE_RUNS_PER_AGENT", 1) + + run_id = _create_v2_run(key, monkeypatch) + # The run finishes: backend goes inactive, but nothing updated the row yet. + with runs_mod._lock: + runs_mod._runs[run_id]["backend"]._active = False + + # Second create must succeed (the terminal run no longer holds a slot). + resp = client.post( + "/api/v2/runs", + json={"start_date": "2026-04-15", "end_date": "2026-04-16"}, + headers={"X-API-Key": key}, + ) + assert resp.status_code == 200, resp.text + # And the reconciled row is terminal now. + assert run_store.get_run(run_id)["status"] in ("completed", "failed", "closed") + + +# --------------------------------------------------------------------------- +# 3. Reaper sweep registration + v2 archival +# --------------------------------------------------------------------------- + + +def test_reap_runs_invokes_registered_sweeps(monkeypatch): + calls = [] + monkeypatch.setattr(run_service, "_extra_reaper_sweeps", []) + run_service.register_reaper_sweep(lambda: calls.append("swept")) + run_service.reap_runs() + assert calls == ["swept"] + + +def test_reap_runs_survives_a_raising_sweep(monkeypatch): + calls = [] + + def _boom(): + raise RuntimeError("sweep exploded") + + monkeypatch.setattr(run_service, "_extra_reaper_sweeps", []) + run_service.register_reaper_sweep(_boom) + run_service.register_reaper_sweep(lambda: calls.append("still swept")) + run_service.reap_runs() # must not raise + assert calls == ["still swept"] + + +def test_v2_sweep_archives_terminal_backends(monkeypatch): + """After the sweep, a finished v2 run: frees its backend (tombstone), has a + terminal row, and still answers status/decisions/replay over HTTP.""" + key, sid, agent_id = _agent("sweeper") + run_id = f"run_sweep_{uuid.uuid4().hex[:6]}" + fake = FakeBackend(run_id=run_id, total_steps=1, session_id=sid) + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + runs_mod.register_run(run_id, fake, sid, agent_id) + # Drive to completion, with an idempotent ack recorded like the live path. + ack = {"accepted": True, "executed": [], "rejected": [], + "decision_source": "external_agent", "next_step": 1, + "status": "completed", "run_id": run_id, "metrics": None} + db.put_idempotency(run_id, 0, "key-before-archive", ack) + fake.apply_decisions([]) + assert fake._status == "completed" + + runs_mod.reap_v2_runs() + + with runs_mod._lock: + archived = runs_mod._runs[run_id]["backend"] + assert isinstance(archived, ArchivedBacktestBackend) + assert run_store.get_run(run_id)["status"] == "completed" + + # Reads still answer. + status = client.get(f"/api/v2/runs/{run_id}", headers={"X-API-Key": key}) + assert status.status_code == 200 + assert status.json()["status"] == "completed" + # A new decision is refused with the live path's terminal error shape. + submit = client.post( + f"/api/v2/runs/{run_id}/decisions", + json={"idempotency_key": "fresh-key", "actions": []}, + headers={"X-API-Key": key}, + ) + assert submit.status_code == 409 + assert submit.json()["error"]["code"] == "invalid_status" + # An idempotent replay still returns the recorded ack (DB-backed). + replay = client.post( + f"/api/v2/runs/{run_id}/decisions", + json={"idempotency_key": "key-before-archive", "actions": []}, + headers={"X-API-Key": key}, + ) + assert replay.status_code == 200 + assert replay.json()["status"] == "completed" + + +def test_v2_sweep_drives_elapsed_deadlines(monkeypatch): + """Abandoned lockstep runs must be drained (advance()) by the sweep, not + sit waiting_decision forever.""" + key, sid, agent_id = _agent("drainer") + run_id = f"run_drain_{uuid.uuid4().hex[:6]}" + + class _Abandoned(FakeBackend): + def __init__(self): + super().__init__(run_id=run_id, total_steps=1, session_id=sid) + self.advanced = 0 + + def advance(self): + # Deadline elapsed → engine auto-holds to completion. + self.advanced += 1 + self._status = "completed" + + def is_active(self): + return self._status not in ("completed", "failed", "closed") + + backend = _Abandoned() + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + runs_mod.register_run(run_id, backend, sid, agent_id) + + runs_mod.reap_v2_runs() + + assert backend.advanced >= 1 + assert run_store.get_run(run_id)["status"] == "completed" + + +# --------------------------------------------------------------------------- +# 4. Restart visibility (orphan recovery + rehydration) +# --------------------------------------------------------------------------- + + +def test_v2_run_visible_after_restart_as_failed(monkeypatch): + """Crash-orphaned v2 run: recovery fails the row, and the v2 surface + rehydrates it as a terminal tombstone instead of 404ing the owner.""" + key, sid, agent_id = _agent("restart-owner") + run_id = _create_v2_run(key, monkeypatch) + + # Simulate a process restart: in-memory registry gone. + with runs_mod._lock: + runs_mod._runs.pop(run_id, None) + monkeypatch.setattr(run_service, "RUN_RECOVERY_ON_STARTUP", True) + run_service.recover_orphaned_runs() + assert run_store.get_run(run_id)["status"] == "failed" + + resp = client.get(f"/api/v2/runs/{run_id}", headers={"X-API-Key": key}) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "failed" + + +def test_v2_rehydration_is_not_an_existence_oracle(monkeypatch): + """Terminal-row rehydration must answer a foreign prober exactly like a + missing run — same code, same message template.""" + key_owner, _, _ = _agent("rehydrate-owner") + key_probe, _, _ = _agent("rehydrate-prober") + run_id = _create_v2_run(key_owner, monkeypatch) + with runs_mod._lock: + runs_mod._runs.pop(run_id, None) + run_store.update_run(run_id, status="failed") + + denied = client.get(f"/api/v2/runs/{run_id}", headers={"X-API-Key": key_probe}) + missing = client.get( + f"/api/v2/runs/run_missing_{uuid.uuid4().hex[:6]}", + headers={"X-API-Key": key_probe}, + ) + assert denied.status_code == missing.status_code == 404 + assert denied.json()["error"]["code"] == missing.json()["error"]["code"] == "run_not_found" + + +# --------------------------------------------------------------------------- +# 5. Instance-id / heartbeat hardening (multi-worker recovery) +# --------------------------------------------------------------------------- + + +def test_create_run_stamps_instance_and_heartbeat(tmp_path): + store = RunStore(tmp_path / "hb.db") + rec = store.create_run( + agent_id="ag_hb", agent_version_id=None, session_id="sess", + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + conn = sqlite3.connect(str(store.db_path)) + row = conn.execute( + "SELECT owner_instance, heartbeat_at FROM protocol_runs WHERE run_id = ?", + (rec["run_id"],), + ).fetchone() + conn.close() + assert row[0] == run_store_module.INSTANCE_ID + assert row[1] # heartbeat stamped at creation + + +def test_fail_stale_runs_spares_fresh_heartbeats(tmp_path): + store = RunStore(tmp_path / "stale.db") + fresh = store.create_run( + agent_id="ag_live", agent_version_id=None, session_id="s", + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + stale = store.create_run( + agent_id="ag_dead", agent_version_id=None, session_id="s", + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + conn = sqlite3.connect(str(store.db_path)) + conn.execute( + "UPDATE protocol_runs SET heartbeat_at = '2020-01-01T00:00:00+00:00' " + "WHERE run_id = ?", (stale["run_id"],), + ) + conn.commit() + conn.close() + + failed = store.fail_stale_runs(stale_seconds=300) + + assert failed == 1 + assert store.get_run(stale["run_id"])["status"] == "failed" + assert store.get_run(fresh["run_id"])["status"] == "running" + + +def test_fail_stale_runs_treats_legacy_null_heartbeat_as_stale(tmp_path): + """Rows written before the heartbeat column existed must still be + recoverable — fall back to updated_at for staleness.""" + store = RunStore(tmp_path / "legacy.db") + rec = store.create_run( + agent_id="ag_legacy", agent_version_id=None, session_id="s", + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + conn = sqlite3.connect(str(store.db_path)) + conn.execute( + "UPDATE protocol_runs SET heartbeat_at = NULL, " + "updated_at = '2020-01-01T00:00:00+00:00' WHERE run_id = ?", + (rec["run_id"],), + ) + conn.commit() + conn.close() + + assert store.fail_stale_runs(stale_seconds=300) == 1 + assert store.get_run(rec["run_id"])["status"] == "failed" + + +def test_runstore_migrates_legacy_schema(tmp_path): + """A DB created before the heartbeat columns must gain them on open.""" + path = tmp_path / "old.db" + conn = sqlite3.connect(str(path)) + conn.execute( + """ + CREATE TABLE protocol_runs ( + run_id TEXT PRIMARY KEY, + agent_id TEXT, + agent_version_id TEXT, + session_id TEXT NOT NULL, + environment_id TEXT, + environment_type TEXT, + config TEXT NOT NULL DEFAULT '{}', + backtest_id TEXT, + result_run_id TEXT, + status TEXT NOT NULL DEFAULT 'created', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "INSERT INTO protocol_runs (run_id, session_id, status) " + "VALUES ('run_old', 'sess', 'running')" + ) + conn.commit() + conn.close() + + store = RunStore(path) # must migrate, not crash + conn = sqlite3.connect(str(path)) + cols = {row[1] for row in conn.execute("PRAGMA table_info(protocol_runs)")} + conn.close() + assert {"owner_instance", "heartbeat_at"} <= cols + assert store.get_run("run_old")["status"] == "running" + + +def _make_completed_v2_run(monkeypatch_none, name): + """A finished-but-not-yet-archived v2 run: terminal backend still live in + the registry, protocol_runs row already 'completed' (the state between a + final decision and the reaper's next sweep).""" + key, sid, agent_id = _agent(name) + run_id = f"run_{name}_{uuid.uuid4().hex[:6]}" + fake = FakeBackend(run_id=run_id, total_steps=1, session_id=sid) + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + runs_mod.register_run(run_id, fake, sid, agent_id) + fake.apply_decisions([]) # completes the fake + run_store.update_run(run_id, status="completed", result_run_id=run_id) + return key, run_id, fake + + +# --------------------------------------------------------------------------- +# 6. Adversarial-review round (confirmed findings) +# --------------------------------------------------------------------------- + + +def test_cancel_after_completion_does_not_clobber_the_ledger(monkeypatch): + """CRITICAL finding: a cancel arriving after the final decision (but + before the sweep archives the backend) must not downgrade the row's + 'completed' to 'closed' — the run genuinely finished; report the truth.""" + key, run_id, fake = _make_completed_v2_run(monkeypatch, "clobber") + + resp = client.post(f"/api/v2/runs/{run_id}/cancel", headers={"X-API-Key": key}) + + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" # the truth, not "closed" + assert run_store.get_run(run_id)["status"] == "completed" + assert fake._status == "completed" # in-memory state not clobbered either + + +def test_cancel_of_archived_run_reports_true_terminal_state(monkeypatch): + key, run_id, fake = _make_completed_v2_run(monkeypatch, "arccancel") + runs_mod.reap_v2_runs() # archive it + + resp = client.post(f"/api/v2/runs/{run_id}/cancel", headers={"X-API-Key": key}) + + assert resp.status_code == 200 + assert resp.json()["status"] == "completed" + assert run_store.get_run(run_id)["status"] == "completed" + + +def test_backtest_backend_cancel_never_clobbers_a_terminal_session(): + """The in-memory guard: cancel() on an already-completed session must not + flip it to 'closed' (a race between a finalizing submit and a cancel + would otherwise corrupt what every later read derives its state from).""" + import threading + + from dashboard.backend.execution.backtest_backend import BacktestBackend + + class _Session: + _step_lock = threading.Lock() + status = "completed" + + backend = BacktestBackend.__new__(BacktestBackend) + backend.run_id = "run_cancel_guard" + backend.session = _Session() + backend.cancel() + assert backend.session.status == "completed" + + backend.session.status = "waiting_decision" + backend.cancel() + assert backend.session.status == "closed" + + +def test_archive_run_keeps_backend_live_when_row_write_fails(monkeypatch): + """If the terminal row update fails (locked DB), swapping in the + tombstone anyway would freeze the row in an active status with nothing + left to retry — the swap must be gated on the write landing.""" + key, sid, agent_id = _agent("swap-order") + run_id = f"run_swap_{uuid.uuid4().hex[:6]}" + fake = FakeBackend(run_id=run_id, total_steps=1, session_id=sid) + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + runs_mod.register_run(run_id, fake, sid, agent_id) + fake.apply_decisions([]) + + def _boom(*a, **k): + raise RuntimeError("db locked") + + monkeypatch.setattr(run_store, "update_run", _boom) + runs_mod.reap_v2_runs() + with runs_mod._lock: + still_live = runs_mod._runs[run_id]["backend"] + assert not isinstance(still_live, ArchivedBacktestBackend), ( + "backend must stay live so the next sweep can retry the row write" + ) + + monkeypatch.undo() + runs_mod.reap_v2_runs() + with runs_mod._lock: + archived = runs_mod._runs[run_id]["backend"] + assert isinstance(archived, ArchivedBacktestBackend) + assert run_store.get_run(run_id)["status"] == "completed" + + +def test_v2_row_transitions_to_running_after_load(monkeypatch): + """Rows must not sit in 'loading' for the whole active life of the run — + a successful market-data load flips them to 'running'.""" + import time as _time + + from dashboard.backend.execution.backtest_backend import BacktestBackend + + import threading + + class _InstantLoadSession: + def __init__(self): + self._step_lock = threading.Lock() + self.status = "loading" + + def load_market_data(self): + self.status = "waiting_decision" + + run_id = f"run_load_{uuid.uuid4().hex[:6]}" + run_store.create_run( + run_id=run_id, agent_id="ag_load", agent_version_id=None, + session_id="sess_load", environment_id=None, environment_type="backtest", + config={}, backtest_id=None, status="loading", + ) + backend = BacktestBackend.__new__(BacktestBackend) + backend.run_id = run_id + backend.session = _InstantLoadSession() + backend.start_background_load() + deadline = _time.time() + 5 + while _time.time() < deadline: + if run_store.get_run(run_id)["status"] == "running": + break + _time.sleep(0.02) + assert run_store.get_run(run_id)["status"] == "running" + + +def test_runstore_survives_partially_migrated_schema(tmp_path): + """A concurrently-started sibling may have added one heartbeat column + already — startup must add only the missing one, without crashing.""" + path = tmp_path / "partial.db" + conn = sqlite3.connect(str(path)) + conn.execute( + """ + CREATE TABLE protocol_runs ( + run_id TEXT PRIMARY KEY, + agent_id TEXT, + agent_version_id TEXT, + session_id TEXT NOT NULL, + environment_id TEXT, + environment_type TEXT, + config TEXT NOT NULL DEFAULT '{}', + backtest_id TEXT, + result_run_id TEXT, + status TEXT NOT NULL DEFAULT 'created', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + owner_instance TEXT + ) + """ + ) + conn.commit() + conn.close() + + store = RunStore(path) # must add only heartbeat_at, without crashing + conn = sqlite3.connect(str(path)) + cols = {row[1] for row in conn.execute("PRAGMA table_info(protocol_runs)")} + conn.close() + assert {"owner_instance", "heartbeat_at"} <= cols + + +def test_runstore_migration_tolerates_losing_the_alter_race(tmp_path): + """Two workers can both probe (columns missing) and both ALTER; the loser + gets 'duplicate column name'. That means the column exists — the goal — + so it must be swallowed, not crash the process at startup.""" + store = RunStore(tmp_path / "race.db") # columns already exist + conn = sqlite3.connect(str(store.db_path)) + try: + # Simulate the raced probe: a stale 'nothing exists yet' snapshot. + store._add_recovery_columns(conn.cursor(), existing_columns=set()) + finally: + conn.close() + + +def test_rehydrated_completed_run_reports_step_counts(monkeypatch): + """from_record used to lose step_index/total_steps (0/0) for + restart-rehydrated runs; for completed runs the decision log has one row + per executed step, so the counts are recoverable.""" + key, sid, agent_id = _agent("rehydrate-steps") + run_id = f"run_steps_{uuid.uuid4().hex[:6]}" + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, session_id=sid, + environment_id=None, environment_type="backtest", config={}, + backtest_id=None, status="running", + ) + run_store.update_run(run_id, status="completed", result_run_id=run_id) + db.insert_decisions(run_id, [ + {"step_index": i, "timestamp": f"2026-04-15T1{i}:30:00+00:00", + "decision_source": "external_agent", "actions_submitted": [], + "actions_executed": 0} + for i in range(3) + ]) + + resp = client.get(f"/api/v2/runs/{run_id}", headers={"X-API-Key": key}) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + assert body["step_index"] == 3 + assert body["total_steps"] == 3 + + +def test_reap_pass_heartbeats_live_v1_runs(monkeypatch): + """The reaper keeps live runs' heartbeats fresh so a sibling worker's + stale-recovery never fails them.""" + import dashboard.backend.domain.backtesting.external_run_service as ebs + + rec = run_store.create_run( + agent_id="ag_hb_live", agent_version_id=None, session_id="sess_hb", + environment_id="us-equity-hourly-v1", environment_type="backtest", + config={}, backtest_id="bt_hb_live", status="running", + ) + conn = sqlite3.connect(str(run_store.db_path)) + conn.execute( + "UPDATE protocol_runs SET heartbeat_at = '2020-01-01T00:00:00+00:00' " + "WHERE run_id = ?", (rec["run_id"],), + ) + conn.commit() + conn.close() + + class _LiveSession: + def drain_expired(self): + return "waiting_decision" + + def get_status(self): + return {"status": "waiting_decision"} + + run = run_service.ProtocolRun( + record=rec, environment={"type": "backtest", "constraints": {}}, + ) + monkeypatch.setitem(ebs._sessions, "bt_hb_live", _LiveSession()) + with run_service._registry_lock: + run_service._runs[rec["run_id"]] = run + try: + run_service.reap_runs() + finally: + with run_service._registry_lock: + run_service._runs.pop(rec["run_id"], None) + + conn = sqlite3.connect(str(run_store.db_path)) + (hb,) = conn.execute( + "SELECT heartbeat_at FROM protocol_runs WHERE run_id = ?", + (rec["run_id"],), + ).fetchone() + conn.close() + assert hb and hb > "2020-01-02", "reaper must refresh live runs' heartbeats" diff --git a/dashboard/backend/tests/test_sqlite_wal.py b/dashboard/backend/tests/test_sqlite_wal.py new file mode 100644 index 0000000..c353815 --- /dev/null +++ b/dashboard/backend/tests/test_sqlite_wal.py @@ -0,0 +1,71 @@ +"""WAL journal mode for the shared SQLite file (PR #67 H4 follow-up). + +The protocol RunStore and the main BacktestDatabase share one SQLite file. +Under the default rollback journal, any writer blocks all readers for the +duration of the write — and finalize writes (equity series + trades) are +heavy. WAL lets readers proceed while one writer commits, which is the +actual concurrency shape here (request threads read while a backtest +finalizes). journal_mode=WAL is a persistent property of the database +file, so enabling it once at construction covers every later connection. +""" + +import sqlite3 + +from dashboard.backend.database import BacktestDatabase +from dashboard.backend.domain.runs.repository import RunStore + + +def _journal_mode(db_path) -> str: + conn = sqlite3.connect(str(db_path)) + try: + (mode,) = conn.execute("PRAGMA journal_mode").fetchone() + finally: + conn.close() + return str(mode).lower() + + +def test_backtest_database_enables_wal(tmp_path): + db = BacktestDatabase(tmp_path / "wal_main.db") + assert _journal_mode(db.db_path) == "wal" + + +def test_run_store_enables_wal(tmp_path): + store = RunStore(tmp_path / "wal_store.db") + assert _journal_mode(store.db_path) == "wal" + + +def test_wal_persists_when_both_layers_share_one_file(tmp_path): + """Whichever layer initializes first, the shared file ends up in WAL.""" + path = tmp_path / "shared.db" + BacktestDatabase(path) + RunStore(path) + assert _journal_mode(path) == "wal" + + +def test_backtest_database_connections_wait_for_locks(tmp_path): + """Connections should wait (busy_timeout) instead of failing fast when a + concurrent writer holds the lock — RunStore already does this; the main + wrapper shares the same file.""" + db = BacktestDatabase(tmp_path / "wal_busy.db") + conn = db._get_connection() + try: + (timeout_ms,) = conn.execute("PRAGMA busy_timeout").fetchone() + finally: + conn.close() + assert int(timeout_ms) >= 5000 + + +def test_wal_database_still_readable_and_writable(tmp_path): + """Basic round-trip through the wrapper API under WAL.""" + db = BacktestDatabase(tmp_path / "wal_rw.db") + db.insert_run( + run_id="wal_test_run", + session_id="wal-session", + agent_name="wal-agent", + mode="external", + start_date="2025-01-01", + end_date="2025-01-31", + initial_equity=100000.0, + ) + runs = db.get_runs_by_session("wal-session") + assert [r["run_id"] for r in runs] == ["wal_test_run"] diff --git a/dashboard/backend/tests/test_v2_merge_hardening.py b/dashboard/backend/tests/test_v2_merge_hardening.py index 9b612b4..a7a01d1 100644 --- a/dashboard/backend/tests/test_v2_merge_hardening.py +++ b/dashboard/backend/tests/test_v2_merge_hardening.py @@ -298,13 +298,17 @@ def apply_decisions(self, actions): # -- cap counting must be passive (no engine side effects under the lock) ------ def test_active_run_count_is_side_effect_free(): - """Counting active runs happens under the global create lock; it must use - the passive is_active() peek — status() can cascade into - _maybe_apply_timeout/_finalize (seconds of baselines) on a live session.""" + """Counting active runs happens under the global create lock; on a LIVE + backend it must stay passive — only the is_active() peek, never status() + or advance(), which can cascade into _maybe_apply_timeout/_finalize + (seconds of baselines). (Counting itself now reads the protocol_runs + ledger shared with v1; the registry walk only reconciles finished + backends, so a live one must be left completely untouched.)""" class _TrackingBackend: def __init__(self): self.status_called = False + self.advance_called = False def is_active(self): return True @@ -313,14 +317,29 @@ def status(self): self.status_called = True return {"status": "waiting_decision"} + def advance(self): + self.advance_called = True + + from dashboard.backend.domain.runs.repository import run_store + backend = _TrackingBackend() agent_id = f"agent_passive_{uuid.uuid4().hex[:6]}" - runs_mod.register_run(f"run_passive_{uuid.uuid4().hex[:6]}", - backend, "sid_passive", agent_id) + run_id = f"run_passive_{uuid.uuid4().hex[:6]}" + # The count reads the shared protocol_runs ledger (one row per v2 run). + run_store.create_run( + run_id=run_id, agent_id=agent_id, agent_version_id=None, + session_id="sid_passive", environment_id=None, + environment_type="backtest", config={}, backtest_id=None, + status="running", + ) + runs_mod.register_run(run_id, backend, "sid_passive", agent_id) assert runs_mod._active_run_count(agent_id) == 1 assert backend.status_called is False, ( "cap counting must use the passive is_active() peek, not status()" ) + assert backend.advance_called is False, ( + "cap counting must never drive the run forward" + ) # -- 429s carry the rate-limit headers ------------------------------------------ diff --git a/dashboard/frontend/assets/index-taTQ6Gvv.js b/dashboard/frontend/assets/index-BfeC0vcL.js similarity index 100% rename from dashboard/frontend/assets/index-taTQ6Gvv.js rename to dashboard/frontend/assets/index-BfeC0vcL.js diff --git a/dashboard/frontend/assets/index-CR64dUH3.css b/dashboard/frontend/assets/index-CR64dUH3.css deleted file mode 100644 index 4b5700e..0000000 --- a/dashboard/frontend/assets/index-CR64dUH3.css +++ /dev/null @@ -1 +0,0 @@ -@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Inter", sans-serif;--default-mono-font-family:"JetBrains Mono", monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,sans-serif}body ::selection{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){body ::selection{background-color:color-mix(in oklab,hsl(var(--primary)) 30%,transparent)}}body::selection{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){body::selection{background-color:color-mix(in oklab,hsl(var(--primary)) 30%,transparent)}}body ::selection{color:hsl(var(--primary))}body::selection{color:hsl(var(--primary))}}@layer components;@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{top:0;right:0;bottom:0;left:0}.top-0{top:0}.top-12{top:calc(var(--spacing) * 12)}.right-0{right:0}.right-\[15\%\]{right:15%}.left-0{left:0}.left-\[15\%\]{left:15%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.col-span-2{grid-column:span 2/span 2}.col-span-4{grid-column:span 4/span 4}.col-start-2{grid-column-start:2}.col-start-3{grid-column-start:3}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.mb-16{margin-bottom:calc(var(--spacing) * 16)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1px\]{height:1px}.h-\[400px\]{height:400px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[480px\]{max-height:480px}.min-h-0{min-height:0}.min-h-\[90vh\]{min-height:90vh}.min-h-\[480px\]{min-height:480px}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-\[85\%\]{max-width:85%}.max-w-\[88\%\]{max-width:88%}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.animate-pulse{animation:var(--animate-pulse)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}.gap-16{gap:calc(var(--spacing) * 16)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-l-lg{border-top-left-radius:var(--radius);border-bottom-left-radius:var(--radius)}.rounded-r-lg{border-top-right-radius:var(--radius);border-bottom-right-radius:var(--radius)}.rounded-br-lg{border-bottom-right-radius:var(--radius)}.rounded-bl-lg{border-bottom-left-radius:var(--radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:hsl(var(--border))}.border-card-border{border-color:hsl(var(--card-border))}.border-input{border-color:hsl(var(--input))}.border-primary\/25{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/25{border-color:color-mix(in oklab,hsl(var(--primary)) 25%,transparent)}}.border-primary\/50{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,hsl(var(--primary)) 50%,transparent)}}.border-secondary-border{border-color:hsl(var(--secondary-border))}.border-transparent{border-color:#0000}.bg-\[\#5865F2\]{background-color:#5865f2}.bg-background,.bg-background\/50{background-color:hsl(var(--background))}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,hsl(var(--background)) 50%,transparent)}}.bg-background\/80{background-color:hsl(var(--background))}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,hsl(var(--background)) 80%,transparent)}}.bg-border{background-color:hsl(var(--border))}.bg-card,.bg-card\/50{background-color:hsl(var(--card))}@supports (color:color-mix(in lab,red,red)){.bg-card\/50{background-color:color-mix(in oklab,hsl(var(--card)) 50%,transparent)}}.bg-destructive,.bg-destructive\/80{background-color:hsl(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/80{background-color:color-mix(in oklab,hsl(var(--destructive)) 80%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-muted,.bg-muted\/20{background-color:hsl(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/20{background-color:color-mix(in oklab,hsl(var(--muted)) 20%,transparent)}}.bg-muted\/50{background-color:hsl(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,hsl(var(--muted)) 50%,transparent)}}.bg-positive,.bg-positive\/10{background-color:hsl(var(--positive))}@supports (color:color-mix(in lab,red,red)){.bg-positive\/10{background-color:color-mix(in oklab,hsl(var(--positive)) 10%,transparent)}}.bg-positive\/80{background-color:hsl(var(--positive))}@supports (color:color-mix(in lab,red,red)){.bg-positive\/80{background-color:color-mix(in oklab,hsl(var(--positive)) 80%,transparent)}}.bg-primary,.bg-primary\/10{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,hsl(var(--primary)) 10%,transparent)}}.bg-primary\/15{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/15{background-color:color-mix(in oklab,hsl(var(--primary)) 15%,transparent)}}.bg-primary\/20{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/20{background-color:color-mix(in oklab,hsl(var(--primary)) 20%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary-border{background-color:hsl(var(--secondary-border))}.bg-slate-500\/10{background-color:#62748e1a}@supports (color:color-mix(in lab,red,red)){.bg-slate-500\/10{background-color:color-mix(in oklab,var(--color-slate-500) 10%,transparent)}}.bg-transparent{background-color:#0000}.\[mask-image\:radial-gradient\(ellipse_at_center\,black\,transparent_70\%\)\]{-webkit-mask-image:radial-gradient(#000,#0000 70%);mask-image:radial-gradient(#000,#0000 70%)}.\[mask-image\:radial-gradient\(ellipse_at_center\,black\,transparent_80\%\)\]{-webkit-mask-image:radial-gradient(#000,#0000 80%);mask-image:radial-gradient(#000,#0000 80%)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-0{padding-top:0}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-36{padding-top:calc(var(--spacing) * 36)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,monospace}.font-sans{font-family:Inter,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[clamp\(2\.5rem\,3\.2vw\,3\.625rem\)\]{font-size:clamp(2.5rem,3.2vw,3.625rem)}.leading-\[1\.05\]{--tw-leading:1.05;line-height:1.05}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.04em\]{--tw-tracking:-.04em;letter-spacing:-.04em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-\[\#22d3ee\]{color:#22d3ee}.text-\[\#e5e7eb\]{color:#e5e7eb}.text-card-foreground{color:hsl(var(--card-foreground))}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-400{color:var(--color-emerald-400)}.text-foreground{color:hsl(var(--foreground))}.text-gray-600{color:var(--color-gray-600)}.text-gray-900{color:var(--color-gray-900)}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-positive{color:hsl(var(--positive))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-slate-400{color:var(--color-slate-400)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.opacity-10{opacity:.1}.opacity-30{opacity:.3}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:border-primary\/50:hover{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:border-primary\/50:hover{border-color:color-mix(in oklab,hsl(var(--primary)) 50%,transparent)}}.hover\:bg-\[\#5865F2\]\/90:hover{background-color:#5865f2e6}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,hsl(var(--destructive)) 90%,transparent)}}.hover\:bg-muted:hover{background-color:hsl(var(--muted))}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,hsl(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,hsl(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}}@media(min-width:48rem){.md\:mt-0{margin-top:0}.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:pt-44{padding-top:calc(var(--spacing) * 44)}.md\:pb-32{padding-bottom:calc(var(--spacing) * 32)}.md\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media(min-width:64rem){.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:mx-0{margin-inline:0}.lg\:w-1\/3{width:33.3333%}.lg\:w-2\/3{width:66.6667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:text-left{text-align:left}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root,.dark{--background:232 59% 10%;--foreground:210 40% 98%;--border:224 42% 20%;--input:224 42% 20%;--ring:188 84% 53%;--card:223 58% 13%;--card-foreground:210 40% 98%;--card-border:224 42% 20%;--popover:223 58% 13%;--popover-foreground:210 40% 98%;--popover-border:224 42% 20%;--primary:188 84% 53%;--primary-foreground:204 71% 5%;--primary-border:188 84% 53%;--secondary:226 64% 10%;--secondary-foreground:220 14% 91%;--secondary-border:227 28% 22%;--muted:228 47% 14%;--muted-foreground:219 14% 59%;--muted-border:224 42% 20%;--accent:188 84% 53%;--accent-foreground:204 71% 5%;--accent-border:188 84% 53%;--destructive:0 84% 60%;--destructive-foreground:210 40% 98%;--destructive-border:0 84% 50%;--positive:160 84% 39%;--radius:9px}.brand-lockup{color:inherit;align-items:center;gap:12px;text-decoration:none;display:flex}.brand-logo{background:#07182d;border-radius:16px;flex:none;justify-content:center;align-items:center;width:64px;height:64px;display:flex;overflow:hidden}.brand-logo img{object-fit:cover;width:100%;height:100%;display:block;transform:scale(1.22)}.brand-title{color:#e5e7eb;letter-spacing:.3px;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica Neue,sans-serif;font-size:23px;font-weight:700;line-height:1.2}.brand-lockup:hover .brand-title{color:#00bfff}.glow-primary{box-shadow:0 6px 24px #22d3ee3d}.bg-grid-pattern{background-image:linear-gradient(90deg,#1e2a4a80 1px,#0000 1px),linear-gradient(#1e2a4a80 1px,#0000 1px);background-size:40px 40px}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/dashboard/frontend/assets/index-CoQ1VbAs.css b/dashboard/frontend/assets/index-CoQ1VbAs.css new file mode 100644 index 0000000..da667c4 --- /dev/null +++ b/dashboard/frontend/assets/index-CoQ1VbAs.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-white:#fff;--spacing:.25rem;--container-xl:36rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tighter:-.05em;--tracking-widest:.1em;--leading-relaxed:1.625;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"Inter", sans-serif;--default-mono-font-family:"JetBrains Mono", monospace}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,sans-serif}body ::selection{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){body ::selection{background-color:color-mix(in oklab,hsl(var(--primary)) 30%,transparent)}}body::selection{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){body::selection{background-color:color-mix(in oklab,hsl(var(--primary)) 30%,transparent)}}body ::selection{color:hsl(var(--primary))}body::selection{color:hsl(var(--primary))}}@layer components;@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{top:0;right:0;bottom:0;left:0}.top-0{top:0}.top-12{top:calc(var(--spacing) * 12)}.right-0{right:0}.right-\[15\%\]{right:15%}.left-0{left:0}.left-\[15\%\]{left:15%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-1{order:1}.order-2{order:2}.col-span-2{grid-column:span 2/span 2}.col-span-4{grid-column:span 4/span 4}.col-start-2{grid-column-start:2}.col-start-3{grid-column-start:3}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.mb-16{margin-bottom:calc(var(--spacing) * 16)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-\[1px\]{height:1px}.h-\[400px\]{height:400px}.h-\[480px\]{height:480px}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[480px\]{max-height:480px}.min-h-0{min-height:0}.min-h-\[90vh\]{min-height:90vh}.min-h-\[480px\]{min-height:480px}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-\[85\%\]{max-width:85%}.max-w-\[88\%\]{max-width:88%}.max-w-xl{max-width:var(--container-xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.animate-pulse{animation:var(--animate-pulse)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-\[1fr_auto_1fr\]{grid-template-columns:1fr auto 1fr}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}.gap-16{gap:calc(var(--spacing) * 16)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-l-lg{border-top-left-radius:var(--radius);border-bottom-left-radius:var(--radius)}.rounded-r-lg{border-top-right-radius:var(--radius);border-bottom-right-radius:var(--radius)}.rounded-br-lg{border-bottom-right-radius:var(--radius)}.rounded-bl-lg{border-bottom-left-radius:var(--radius)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-border{border-color:hsl(var(--border))}.border-card-border{border-color:hsl(var(--card-border))}.border-input{border-color:hsl(var(--input))}.border-primary\/25{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/25{border-color:color-mix(in oklab,hsl(var(--primary)) 25%,transparent)}}.border-primary\/50{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,hsl(var(--primary)) 50%,transparent)}}.border-secondary-border{border-color:hsl(var(--secondary-border))}.border-transparent{border-color:#0000}.bg-\[\#5865F2\]{background-color:#5865f2}.bg-background,.bg-background\/50{background-color:hsl(var(--background))}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,hsl(var(--background)) 50%,transparent)}}.bg-background\/80{background-color:hsl(var(--background))}@supports (color:color-mix(in lab,red,red)){.bg-background\/80{background-color:color-mix(in oklab,hsl(var(--background)) 80%,transparent)}}.bg-border{background-color:hsl(var(--border))}.bg-card,.bg-card\/50{background-color:hsl(var(--card))}@supports (color:color-mix(in lab,red,red)){.bg-card\/50{background-color:color-mix(in oklab,hsl(var(--card)) 50%,transparent)}}.bg-destructive,.bg-destructive\/80{background-color:hsl(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/80{background-color:color-mix(in oklab,hsl(var(--destructive)) 80%,transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab,red,red)){.bg-emerald-500\/10{background-color:color-mix(in oklab,var(--color-emerald-500) 10%,transparent)}}.bg-muted,.bg-muted\/20{background-color:hsl(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/20{background-color:color-mix(in oklab,hsl(var(--muted)) 20%,transparent)}}.bg-muted\/50{background-color:hsl(var(--muted))}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,hsl(var(--muted)) 50%,transparent)}}.bg-positive,.bg-positive\/10{background-color:hsl(var(--positive))}@supports (color:color-mix(in lab,red,red)){.bg-positive\/10{background-color:color-mix(in oklab,hsl(var(--positive)) 10%,transparent)}}.bg-positive\/80{background-color:hsl(var(--positive))}@supports (color:color-mix(in lab,red,red)){.bg-positive\/80{background-color:color-mix(in oklab,hsl(var(--positive)) 80%,transparent)}}.bg-primary,.bg-primary\/10{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,hsl(var(--primary)) 10%,transparent)}}.bg-primary\/15{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/15{background-color:color-mix(in oklab,hsl(var(--primary)) 15%,transparent)}}.bg-primary\/20{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.bg-primary\/20{background-color:color-mix(in oklab,hsl(var(--primary)) 20%,transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500) 10%,transparent)}}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary-border{background-color:hsl(var(--secondary-border))}.bg-slate-500\/10{background-color:#62748e1a}@supports (color:color-mix(in lab,red,red)){.bg-slate-500\/10{background-color:color-mix(in oklab,var(--color-slate-500) 10%,transparent)}}.bg-transparent{background-color:#0000}.\[mask-image\:radial-gradient\(ellipse_at_center\,black\,transparent_70\%\)\]{-webkit-mask-image:radial-gradient(#000,#0000 70%);mask-image:radial-gradient(#000,#0000 70%)}.\[mask-image\:radial-gradient\(ellipse_at_center\,black\,transparent_80\%\)\]{-webkit-mask-image:radial-gradient(#000,#0000 80%);mask-image:radial-gradient(#000,#0000 80%)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-36{padding-top:calc(var(--spacing) * 36)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,monospace}.font-sans{font-family:Inter,sans-serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[clamp\(2\.5rem\,3\.2vw\,3\.625rem\)\]{font-size:clamp(2.5rem,3.2vw,3.625rem)}.leading-\[1\.05\]{--tw-leading:1.05;line-height:1.05}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.04em\]{--tw-tracking:-.04em;letter-spacing:-.04em}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-\[\#22d3ee\]{color:#22d3ee}.text-\[\#e5e7eb\]{color:#e5e7eb}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-400{color:var(--color-emerald-400)}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-positive{color:hsl(var(--positive))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-400{color:var(--color-red-400)}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-slate-400{color:var(--color-slate-400)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.underline-offset-4{text-underline-offset:4px}.opacity-10{opacity:.1}.opacity-30{opacity:.3}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:border-primary\/50:hover{border-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:border-primary\/50:hover{border-color:color-mix(in oklab,hsl(var(--primary)) 50%,transparent)}}.hover\:bg-\[\#5865F2\]\/90:hover{background-color:#5865f2e6}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,hsl(var(--destructive)) 90%,transparent)}}.hover\:bg-muted:hover{background-color:hsl(var(--muted))}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,hsl(var(--primary)) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary))}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,hsl(var(--secondary)) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}}@media(min-width:48rem){.md\:mt-0{margin-top:0}.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:pt-44{padding-top:calc(var(--spacing) * 44)}.md\:pb-32{padding-bottom:calc(var(--spacing) * 32)}.md\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media(min-width:64rem){.lg\:order-1{order:1}.lg\:order-2{order:2}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:mx-0{margin-inline:0}.lg\:w-1\/3{width:33.3333%}.lg\:w-2\/3{width:66.6667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:text-left{text-align:left}}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root,.dark{--background:232 59% 10%;--foreground:210 40% 98%;--border:224 42% 20%;--input:224 42% 20%;--ring:188 84% 53%;--card:223 58% 13%;--card-foreground:210 40% 98%;--card-border:224 42% 20%;--popover:223 58% 13%;--popover-foreground:210 40% 98%;--popover-border:224 42% 20%;--primary:188 84% 53%;--primary-foreground:204 71% 5%;--primary-border:188 84% 53%;--secondary:226 64% 10%;--secondary-foreground:220 14% 91%;--secondary-border:227 28% 22%;--muted:228 47% 14%;--muted-foreground:219 14% 59%;--muted-border:224 42% 20%;--accent:188 84% 53%;--accent-foreground:204 71% 5%;--accent-border:188 84% 53%;--destructive:0 84% 60%;--destructive-foreground:210 40% 98%;--destructive-border:0 84% 50%;--positive:160 84% 39%;--radius:9px}.brand-lockup{color:inherit;align-items:center;gap:12px;text-decoration:none;display:flex}.brand-logo{background:#07182d;border-radius:16px;flex:none;justify-content:center;align-items:center;width:64px;height:64px;display:flex;overflow:hidden}.brand-logo img{object-fit:cover;width:100%;height:100%;display:block;transform:scale(1.22)}.brand-title{color:#e5e7eb;letter-spacing:.3px;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica Neue,sans-serif;font-size:23px;font-weight:700;line-height:1.2}.brand-lockup:hover .brand-title{color:#00bfff}.glow-primary{box-shadow:0 6px 24px #22d3ee3d}.bg-grid-pattern{background-image:linear-gradient(90deg,#1e2a4a80 1px,#0000 1px),linear-gradient(#1e2a4a80 1px,#0000 1px);background-size:40px 40px}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} diff --git a/dashboard/frontend/index.html b/dashboard/frontend/index.html index 4d798df..4b08695 100644 --- a/dashboard/frontend/index.html +++ b/dashboard/frontend/index.html @@ -17,8 +17,8 @@ - - + +