Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ htmlcov/
coverage.xml
*.cover

# Environment
.env
# Vercel local project link (never commit credentials)
.vercel/
.env.*
!.env.example

Expand Down
27 changes: 27 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
182 changes: 182 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -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).
77 changes: 77 additions & 0 deletions frontend/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const origin = String(
(globalThis as typeof globalThis & { process?: { env?: Record<string, string> } })
.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" } },
);
}
}
41 changes: 39 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 })),
Expand Down Expand Up @@ -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 (
<div className={`api-health-banner api-health-banner--${status}`} role="status">
<span>{label}</span>
<button type="button" className="btn btn--ghost btn--sm" onClick={() => void refresh()}>
Retry
</button>
</div>
);
}

function TopbarActions() {
const { apiKey, setAssistantOpen } = useApp();
const { status } = useApiHealth(120_000);
const secured = Boolean(resolveApiKey(apiKey));
return (
<div className="topbar__actions">
<span
className={`topbar__health topbar__health--${status}`}
title={
status === "ok"
? "API healthy"
: status === "degraded"
? "API degraded"
: "API unreachable"
}
role="status"
aria-live="polite"
>
<span className="topbar__health-label">API status: {status}</span>
</span>
<button
type="button"
className="topbar__assist-btn"
Expand All @@ -75,7 +109,7 @@ function TopbarActions() {
title={
secured
? "API key configured — secured mode"
: "Open dev mode — no API key (set in Connection settings)"
: "Open demo mode — no API key (set in Connection settings)"
}
>
{secured ? "Secured" : "Open"}
Expand Down Expand Up @@ -104,6 +138,7 @@ function AppChrome() {
onOpenCopilot={() => setAssistantOpen(true)}
/>
) : null}
<ApiHealthBanner />
<header className="topbar">
<div className="topbar__brand">
<span className="topbar__logo" aria-hidden>
Expand All @@ -119,7 +154,9 @@ function AppChrome() {

<div className="app__body">
<Sidebar />
<MainContent />
<ErrorBoundary>
<MainContent />
</ErrorBoundary>
<AssistantPanel />
</div>

Expand Down
Loading
Loading