diff --git a/.gitignore b/.gitignore index 768ea40..4a1b295 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,8 @@ htmlcov/ coverage.xml *.cover -# Environment -.env +# Vercel local project link (never commit credentials) +.vercel/ .env.* !.env.example diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..ad4aacb --- /dev/null +++ b/.vercelignore @@ -0,0 +1,27 @@ +# Cortex monorepo — Vercel hosts the Vite dashboard only. +# Whitelist: only frontend + root deploy config are uploaded (keeps bundle < 500 MB). +# Use leading paths where noted so frontend/scripts/ is never excluded by mistake. + +* +!frontend +!frontend/** +!vercel.json +!middleware.ts +!.vercelignore + +# Belt-and-suspenders: never upload Python backend even if patterns change +/api/ +/pipeline/ +/connectors/ +/extraction/ +/graph/ +/intelligence/ +/memory/ +/scoring/ +/shared/ +/mcp/ +/tests/ +pyproject.toml +uv.lock +.python-version +*.py diff --git a/api/Dockerfile b/api/Dockerfile index ad86aa8..6ccea4b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,4 +22,6 @@ RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8000 -CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] +RUN chmod +x scripts/start_api_production.sh + +CMD ["sh", "scripts/start_api_production.sh"] diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..360e8c1 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,182 @@ +# Deploying Cortex + +Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, dashboard). Vercel hosts the **dashboard only**; the API and worker run elsewhere. + +## Recommended split + +| Component | Host | Notes | +|-----------|------|-------| +| Dashboard | **Vercel** (`frontend/`) | Vite static + API rewrites | +| API | Railway / Render / Fly | `api/Dockerfile` | +| pipeline-worker | Same as API | `pipeline/Dockerfile` | +| Neo4j | Neo4j Aura | Bolt URI in env | +| Redis | Upstash | Query cache | +| Kafka | Upstash Kafka / Confluent Cloud | Event bus | + +## Vercel (frontend) + +**Do not deploy the Python API on Vercel.** If the build log shows `Using Python 3.12 from pyproject.toml` or installs `uv.lock`, Vercel is treating the repo as FastAPI — that bundle (~5 GB) exceeds Lambda limits. + +**Fix (pick one):** + +| Approach | Settings | +|----------|----------| +| **Recommended** | Project Settings → General → **Root Directory** → `frontend` → Save → Redeploy | +| **Repo root** | Keep Root Directory `.` — root `vercel.json` + `.vercelignore` upload only `frontend/` (excludes Python/`api/` so the bundle stays under Vercel limits) | + +After changing Root Directory, clear the Framework Preset override if it still says **FastAPI** — it should be **Vite** or **Other**. + +**Project settings (Root Directory = `frontend`)** + +- Root Directory: `frontend` +- Framework: Vite +- Build: `npm run build` +- API proxy: `middleware.ts` reads `CORTEX_API_ORIGIN` at **runtime** (Vercel parses `vercel.json` before build — build-time rewrites cannot inject env vars) + +**Environment variables** + +| Variable | Purpose | +|----------|---------| +| `CORTEX_API_ORIGIN` | Public API URL — Edge Middleware proxies `/query`, `/health`, etc. server-side | +| ~~`VITE_API_URL`~~ | **Do not** point at `loca.lt` — causes 511 tunnel interstitial errors | + +**Do not** import the repo root with Framework Preset **FastAPI**. That installs the full `uv.lock` (~5 GB) and exceeds Lambda limits. + +```bash +cd frontend +npx vercel deploy --prod +``` + +Set `CORTEX_API_ORIGIN=https://your-api.example.com` in the Vercel project before deploy. + +## Local full stack + +```bash +make demo +open http://localhost:3000 # dashboard (nginx → API) +open http://localhost:8000/docs +``` + +## Dev preview (laptop + tunnel) + +For short-lived public demos while the API runs locally: + +```bash +cloudflared tunnel --url http://localhost:8000 +# Set CORTEX_API_ORIGIN to the trycloudflare.com URL on Vercel, redeploy frontend +``` + +Prefer **cloudflared** over localtunnel — localtunnel returns **511** for browser `fetch()` calls. + +## Dual-workspace testing + +See [DATA_SOURCES.md](./DATA_SOURCES.md) for `local-dev` vs `oss-*` workspaces. + +```bash +python scripts/seed_demo.py --workspace local-dev +python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run +make verify-dual +``` + +## Railway / Render (API v1) + +Deploy **only the API** — not Kafka or the pipeline worker. Pre-seed Neo4j with `scripts/seed_demo.py` so `/query` works without live ingestion. + +### Railway + +```bash +# Install CLI: https://docs.railway.com/guides/cli +railway login +railway init # link this repo +railway up # uses railway.toml → api/Dockerfile +``` + +**Required environment variables** (Railway → Variables): + +| Variable | Example | +|----------|---------| +| `NEO4J_URI` | `neo4j+s://xxxx.databases.neo4j.io` | +| `NEO4J_USER` | `neo4j` | +| `NEO4J_PASSWORD` | Aura password | +| `REDIS_URL` | `rediss://default:token@host.upstash.io:6379` | +| `CORTEX_API_KEYS` | `demo-readonly:authenticated` (optional abuse control) | +| `CORTEX_SEMANTIC_ENABLED` | `false` | +| `CORTEX_SEED_DEMO` | `true` **first deploy only** — then set `false` so restarts skip re-seed | + +Copy the public Railway URL (e.g. `https://cortex-api-production.up.railway.app`), set `CORTEX_API_ORIGIN` on Vercel, and redeploy the frontend. + +**Production startup:** `scripts/start_api_production.sh` runs migrations on every boot. Demo seed runs only when `CORTEX_SEED_DEMO=true`. + +### Render + +Use [render.yaml](../render.yaml) as a Blueprint, or create a **Web Service** with Docker runtime and `api/Dockerfile` as the Dockerfile path. Same env vars as Railway. + +### Seed production graph (one-time) + +**Option A — first Railway deploy:** set `CORTEX_SEED_DEMO=true` on the API service, deploy once, then set `CORTEX_SEED_DEMO=false`. + +**Option B — from laptop** with Neo4j credentials in `.env`: + +```bash +export NEO4J_URI=neo4j+s://... +export NEO4J_USER=neo4j +export NEO4J_PASSWORD=... +uv run python graph/migrate.py +uv run python scripts/seed_demo.py --workspace local-dev --scale small +uv run python scripts/import_github_graph.py --org tiangolo --repo fastapi --workspace oss-tiangolo-fastapi --limit 30 +make verify-dual-production +``` + +Direct graph import (no Kafka): [scripts/import_github_graph.py](../scripts/import_github_graph.py). + +### Neo4j Aura migration + +See [AURA_MIGRATION.md](./AURA_MIGRATION.md). Rotate passwords after any credential exposure — update Railway env vars only, never commit secrets. + +### Optional demo API key (abuse control) + +For public demos, use a read-only key so anonymous traffic cannot hammer write endpoints: + +```bash +CORTEX_API_KEYS=demo-readonly:authenticated +``` + +Dashboard users paste the key in **Connection** settings. Open `/query` and `/health` remain usable without a key when `CORTEX_API_KEYS` is unset. + +### Wire Vercel → API + +```bash +# Vercel project → Settings → Environment Variables +CORTEX_API_ORIGIN=https://your-api.railway.app + +cd frontend && npx vercel deploy --prod +``` + +Verify: + +```bash +curl -s https://your-vercel-app.vercel.app/health +curl -s -X POST https://your-vercel-app.vercel.app/query \ + -H "Content-Type: application/json" \ + -d '{"query":"Why CockroachDB?","workspace_id":"local-dev","limit":5}' +``` + +## Auth on preview + +```bash +CORTEX_API_KEYS=preview-key:admin;authenticated +CORTEX_DEMO_API_KEY=preview-key +``` + +`make demo` and `scripts/demo.sh` send `Authorization` when these are set. + +## Optional cloud webhook path (v2) + +Full ingestion uses Kafka + pipeline-worker locally. For cloud v1 without Kafka, use direct graph import: + +```bash +make import-oss-graph # real GitHub PRs → Neo4j +make seed-oss-fastapi # synthetic OSS demo fallback +``` + +Future v2: deploy pipeline-worker as a second Railway service + Upstash Kafka, or add `POST /webhooks/github` → synchronous extract/write for demo scale. See [LAUNCH.md](./LAUNCH.md). diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..14f7bbb --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,77 @@ +/** + * Vercel Edge Middleware — proxy API routes to CORTEX_API_ORIGIN at runtime. + * + * Build-time vercel.json rewrites cannot read env vars on Vercel (config is + * parsed before the build step). Middleware reads CORTEX_API_ORIGIN on each + * request so production deploys pick up dashboard → API wiring without rebuilds. + */ + +export const config = { + matcher: [ + "/health", + "/metrics", + "/query", + "/inject", + "/remember", + "/docs", + "/openapi.json", + "/decisions/:path*", + "/contradictions/:path*", + "/gdpr/:path*", + "/webhooks/:path*", + ], +}; + +const API_PREFIXES = config.matcher.map((m) => m.replace("/:path*", "")); + +function isApiRoute(pathname: string): boolean { + return API_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +export default async function middleware(request: Request): Promise { + const origin = String( + (globalThis as typeof globalThis & { process?: { env?: Record } }) + .process?.env?.CORTEX_API_ORIGIN ?? "", + ) + .trim() + .replace(/\/$/, ""); + if (!origin) { + return new Response( + JSON.stringify({ + detail: + "CORTEX_API_ORIGIN is not set on Vercel. Add your Railway/Render API URL in Project Settings.", + }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + + const url = new URL(request.url); + if (!isApiRoute(url.pathname)) { + return new Response("Not found", { status: 404 }); + } + + const target = `${origin}${url.pathname}${url.search}`; + const headers = new Headers(request.headers); + headers.delete("host"); + + const init: RequestInit = { + method: request.method, + headers, + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + } + + try { + return await fetch(target, init); + } catch (error) { + const message = error instanceof Error ? error.message : "upstream fetch failed"; + return new Response( + JSON.stringify({ detail: `API unreachable at ${origin}: ${message}` }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index baf3c8f..3bea1ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useState } from "react"; import { AppProvider, useApp } from "./context/AppContext"; import { ToastProvider } from "./components/ui/Toast"; +import { ErrorBoundary } from "./components/ui/ErrorBoundary"; import { Sidebar } from "./components/layout/Sidebar"; import { MobileNav } from "./components/layout/MobileNav"; import { AssistantPanel } from "./components/assistant/AssistantPanel"; @@ -12,6 +13,7 @@ import { apiBase } from "./api/client"; import { resolveApiKey } from "./lib/auth"; import { hasCompletedOnboarding } from "./lib/onboarding"; import { BugReportSection } from "./components/layout/BugReportSection"; +import { useApiHealth } from "./hooks/useApiHealth"; const ExploreView = lazy(() => import("./views/ExploreView").then((m) => ({ default: m.ExploreView })), @@ -57,11 +59,43 @@ function MainContent() { ); } +function ApiHealthBanner() { + const { status, refresh } = useApiHealth(); + if (status === "ok" || status === "checking") return null; + const label = + status === "degraded" + ? "API online but a dependency is degraded (Neo4j or Redis)." + : "Cannot reach the Cortex API. Check Connection settings or try again shortly."; + return ( +
+ {label} + +
+ ); +} + function TopbarActions() { const { apiKey, setAssistantOpen } = useApp(); + const { status } = useApiHealth(120_000); const secured = Boolean(resolveApiKey(apiKey)); return (
+ + API status: {status} + - + +
+
+
{secured ? (

@@ -94,9 +146,19 @@ export function WorkspaceBar() {

) : (

- Open mode — no API key sent (fine for local make demo). + Open mode — no API key sent (public demo).

)} + {testOk === true ? ( +

+ API health check passed. +

+ ) : null} + {testOk === false ? ( +

+ API unreachable — verify URL and credentials. +

+ ) : null} ) : null} diff --git a/frontend/src/components/ui/ErrorBoundary.tsx b/frontend/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..c3f8865 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { StateView } from "./StateView"; + +type Props = { + children: ReactNode; +}; + +type State = { + error: Error | null; +}; + +/** Catches render errors so the dashboard never shows a blank screen. */ +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error("Cortex UI error:", error, info.componentStack); + } + + private retry = (): void => { + this.setState({ error: null }); + }; + + render(): ReactNode { + if (this.state.error) { + return ( +
+ + Try again + + } + > + {this.state.error.message || "An unexpected error occurred in the dashboard."} + +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/hooks/useApiHealth.ts b/frontend/src/hooks/useApiHealth.ts new file mode 100644 index 0000000..3b4c856 --- /dev/null +++ b/frontend/src/hooks/useApiHealth.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchHealth } from "../api/client"; +import type { Health } from "../types"; + +export type ApiHealthStatus = "ok" | "degraded" | "unreachable" | "checking"; + +export function useApiHealth(pollMs = 60_000): { + status: ApiHealthStatus; + health: Health | null; + refresh: () => Promise; +} { + const [health, setHealth] = useState(null); + const [status, setStatus] = useState("checking"); + + const refresh = useCallback(async () => { + setStatus("checking"); + try { + const payload = await fetchHealth(); + setHealth(payload); + const neo4j = payload.dependencies?.neo4j; + const redis = payload.dependencies?.redis; + if (payload.status === "ok" && neo4j === "ok" && redis === "ok") { + setStatus("ok"); + } else { + setStatus("degraded"); + } + } catch { + setHealth(null); + setStatus("unreachable"); + } + }, []); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => void refresh(), pollMs); + return () => window.clearInterval(id); + }, [refresh, pollMs]); + + return { status, health, refresh }; +} diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index c6cc789..5b0e27c 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -2,8 +2,8 @@ export function apiUnreachableMessage(): string { return ( - "Cannot reach the Cortex API. Ensure Docker is running, then start the stack with " + - "`make demo` (API on port 8000). If you use the Vite dev server, it proxies to localhost:8000." + "Cannot reach the Cortex API. Check your Connection settings and confirm the API is running. " + + "For local development, start the stack and ensure the dashboard can reach port 8000." ); } @@ -45,6 +45,12 @@ export async function parseHttpError(response: Response): Promise { const base = "Service unavailable (503). Neo4j or another dependency may be down."; return detail ? `${base} ${detail.slice(0, 120)}` : base; } + if (response.status === 511 || detail.includes("Tunnel website ahead")) { + return ( + "API tunnel blocked the request (511). Use same-origin /query via Vercel middleware " + + "(CORTEX_API_ORIGIN), not a tunnel URL in VITE_API_URL." + ); + } if (!detail) return `Request failed (${status})`; return `Request failed (${status}): ${detail.slice(0, 200)}`; } diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..0b65504 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,76 @@ +/** + * Vercel Edge Middleware — proxy API routes to CORTEX_API_ORIGIN at runtime. + * + * Used when the Vercel project Root Directory is the repo root (`cortex` project). + * When Root Directory = `frontend`, see frontend/middleware.ts. + */ + +export const config = { + matcher: [ + "/health", + "/metrics", + "/query", + "/inject", + "/remember", + "/docs", + "/openapi.json", + "/decisions/:path*", + "/contradictions/:path*", + "/gdpr/:path*", + "/webhooks/:path*", + ], +}; + +const API_PREFIXES = config.matcher.map((m) => m.replace("/:path*", "")); + +function isApiRoute(pathname: string): boolean { + return API_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +export default async function middleware(request: Request): Promise { + const origin = String( + (globalThis as typeof globalThis & { process?: { env?: Record } }) + .process?.env?.CORTEX_API_ORIGIN ?? "", + ) + .trim() + .replace(/\/$/, ""); + if (!origin) { + return new Response( + JSON.stringify({ + detail: + "CORTEX_API_ORIGIN is not set on Vercel. Add your Railway/Render API URL in Project Settings.", + }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + + const url = new URL(request.url); + if (!isApiRoute(url.pathname)) { + return new Response("Not found", { status: 404 }); + } + + const target = `${origin}${url.pathname}${url.search}`; + const headers = new Headers(request.headers); + headers.delete("host"); + + const init: RequestInit = { + method: request.method, + headers, + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + } + + try { + return await fetch(target, init); + } catch (error) { + const message = error instanceof Error ? error.message : "upstream fetch failed"; + return new Response( + JSON.stringify({ detail: `API unreachable at ${origin}: ${message}` }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..85d1b1e --- /dev/null +++ b/render.yaml @@ -0,0 +1,36 @@ +# Cortex API — Render Blueprint (Plan A free tier) +# https://render.com/docs/blueprint-spec +# +# Dashboard: Cloudflare Pages (frontend/) — see docs/DEPLOY-FREE.md +# Connect repo → New Blueprint → point at this file. +# Fill secret env vars in the Render dashboard after first deploy. + +services: + - type: web + name: cortex-api + runtime: docker + dockerfilePath: ./api/Dockerfile + dockerContext: . + plan: free + healthCheckPath: /health + envVars: + - key: ENVIRONMENT + value: production + - key: NEO4J_URI + sync: false + - key: NEO4J_USER + value: neo4j + - key: NEO4J_PASSWORD + sync: false + - key: REDIS_URL + sync: false + - key: CORS_ORIGINS + sync: false + - key: CORTEX_API_KEYS + sync: false + - key: CORTEX_SEED_DEMO + value: "true" + - key: CORTEX_SEMANTIC_ENABLED + value: "false" + - key: EXTRACTION_BACKEND + value: heuristic diff --git a/scripts/start_api_production.sh b/scripts/start_api_production.sh new file mode 100755 index 0000000..02f5748 --- /dev/null +++ b/scripts/start_api_production.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Production API entrypoint — migrate graph schema, optional one-time seed, then serve. +# +# CORTEX_SEED_DEMO defaults to false so redeploys do not re-seed. Set to "true" only +# on first boot or when intentionally resetting demo data. +set -eu + +echo "Running Neo4j migrations..." +python graph/migrate.py + +if [ "${CORTEX_SEED_DEMO:-false}" = "true" ]; then + echo "Seeding local-dev demo workspace (CORTEX_SEED_DEMO=true)..." + python scripts/seed_demo.py --workspace local-dev --scale small +else + echo "Skipping demo seed (CORTEX_SEED_DEMO is not true)." +fi + +echo "Starting uvicorn on port ${PORT:-8000}..." +exec uvicorn api.main:app --host 0.0.0.0 --port "${PORT:-8000}" diff --git a/scripts/verify_free_deploy.sh b/scripts/verify_free_deploy.sh new file mode 100755 index 0000000..8e12503 --- /dev/null +++ b/scripts/verify_free_deploy.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Smoke-test Plan A deploy: Render API + optional Cloudflare Pages dashboard. +set -euo pipefail + +API="" +PAGES="" + +usage() { + echo "Usage: $0 --api URL [--pages URL]" + echo " --api Render API base URL (required)" + echo " --pages Cloudflare Pages URL (optional HEAD check)" + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + --api) API="${2%/}"; shift 2 ;; + --pages) PAGES="${2%/}"; shift 2 ;; + -h|--help) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac +done + +[ -n "$API" ] || usage + +echo "==> Health: $API/health" +health=$(curl -sf "$API/health") +echo "$health" | head -c 200 +echo "" + +echo "==> Query: local-dev" +result=$(curl -sf -X POST "$API/query" \ + -H "Content-Type: application/json" \ + -d '{"query":"Why CockroachDB?","workspace_id":"local-dev","limit":5}') +count=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))") +echo "total=$count" +if [ "$count" -lt 1 ]; then + echo "FAIL: expected at least 1 decision — seed Neo4j or set CORTEX_SEED_DEMO=true on Render" + exit 1 +fi + +if [ -n "$PAGES" ]; then + echo "==> Pages HEAD: $PAGES" + curl -sfI "$PAGES" | head -5 +fi + +echo "OK — Plan A API checks passed" diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..1c6078e --- /dev/null +++ b/vercel.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "cd frontend && npm install", + "buildCommand": "cd frontend && npm run build", + "outputDirectory": "frontend/dist", + "rewrites": [ + { + "source": "/((?!assets/).*)", + "destination": "/index.html" + } + ] +}