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
7 changes: 4 additions & 3 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ name: Docker image build

# Validates that the production app and worker images build cleanly from the
# tree — the deploy contract in docs/deployment-architecture.md ("Images are
# built from main"). Runs the exact `npm ci` + `next build` the Fly.io / Cloud
# Run deploy runs, on a runner with enough memory for the build's 8 GiB heap
# (local Docker Desktop OOMs on this build; GitHub runners have ~16 GiB).
# built from main"). Runs the exact `npm ci` + `next build` the Railway deploy
# runs, on a runner with enough memory for the build's 8 GiB heap (local Docker
# Desktop OOMs on this build; GitHub runners and Railway's remote builder have
# enough headroom).
#
# Non-blocking: this is a standalone workflow, not part of the required `verify`
# check. Nothing is pushed to a registry — the build either succeeds or fails.
Expand Down
272 changes: 193 additions & 79 deletions docs/deployment-architecture.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`
- `/api/documents/bulk/reindex` - Bulk reindex operation. Source: `src/app/api/documents/bulk/reindex/route.ts`.
- `/api/eval-cases` - Evaluation case data. Source: `src/app/api/eval-cases/route.ts`.
- `/api/health` - Health check. Source: `src/app/api/health/route.ts`.
- `/api/health/ready` - Route discovered from app directory Source: `src/app/api/health/ready/route.ts`.
- `/api/images/[id]/signed-url` - Private image signed URL. Source: `src/app/api/images/[id]/signed-url/route.ts`.
- `/api/ingestion/batches` - Ingestion batch state. Source: `src/app/api/ingestion/batches/route.ts`.
- `/api/ingestion/jobs` - Ingestion job collection. Source: `src/app/api/ingestion/jobs/route.ts`.
Expand Down
63 changes: 35 additions & 28 deletions docs/staging-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ those vars are unset.

2. **Apply the schema.** From a checkout linked to the staging project:

```bash
````bash
supabase link --project-ref <staging-ref>
supabase db push # applies supabase/migrations/* → matches schema.sql
```
```dotenv

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the schema command fence

In the staging setup runbook, this starts a literal ```dotenv marker inside the four-backtick bash block opened above, so the following health-check prose is rendered as part of the shell snippet. When an operator copies the “Apply the schema” commands, they can paste the stray fence/prose into a shell before running the health check; close the bash fence after supabase db push and keep the health note as normal text.

Useful? React with 👍 / 👎.


Then confirm health: `npm run check:indexing` (runs `search_schema_health()`
over the hybrid RPCs) should report ok.

````

3. **Seed a small corpus (~50 docs).** Use synthetic/public content only — do
**not** copy clinical production documents into staging.

Expand All @@ -51,44 +53,47 @@ those vars are unset.

## B. Staging app host (compute tier)

Recommended host: any OCI-image host. Production runs on **Railway**; Google
Cloud Run `australia-southeast2` is a Sydney-region alternative if lower
answer latency matters. No MCP is available here, so this is an operator step.
Host: **Railway**, same as production (see `docs/deployment-architecture.md` §2).
Stand staging up as a **second environment** in the `clinical-kb` Railway project
(e.g. a `staging` environment) or a separate project, with its own `app` (+
optional `worker`) service pinned to **Southeast Asia (`asia-southeast1-eqsg3a`,
Singapore)** — the closest region to the staging Supabase project in Sydney.
Reuse the same images; only the environment variables differ.

1. **Build the image** from the committed `Dockerfile`, passing the _staging_
publishable key (it inlines into the client bundle):
1. **Build the image** — Railway builds it remotely from the committed
`Dockerfile` on deploy, so there is no local `docker build` and no local
8 GiB-heap OOM. The two `NEXT_PUBLIC_*` build args inline into the client
bundle; set them as **staging** service variables and Railway exposes them to
the build via the Dockerfile `ARG`s:

```bash
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://<staging-ref>.supabase.co \
--build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<staging sb_publishable_…> \
-t clinical-kb-app:staging .
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Note: local Docker Desktop can OOM on the 8 GiB Next build heap; prefer the
CI image-build workflow (builds on GitHub runners) if the local build wedges.
NEXT_PUBLIC_SUPABASE_URL = https://<staging-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY = <staging sb_publishable_…>
```

2. **Runtime secrets** (injected at deploy, never baked into the image) — all
with **staging** values, distinct from production:

| Var | Value |
| ------------------------------- | --------------------------------- |
| `SUPABASE_SERVICE_ROLE_KEY` | staging `sb_secret_…` |
| `OPENAI_API_KEY` | an OpenAI key (staging or shared) |
| `SUPABASE_PROJECT_REF` | `<staging-ref>` |
| `SUPABASE_PROJECT_NAME` | `Clinical KB Staging` |
| `SUPABASE_STAGING_PROJECT_REF` | `<staging-ref>` |
| `SUPABASE_STAGING_PROJECT_NAME` | `Clinical KB Staging` |
| `RAG_QUERY_HASH_SECRET` | a staging secret |
| `RAG_PROVIDER_MODE` | `auto` |
| Var | Value |
| ------------------------------- | --------------------------------------------------------- |
| `SUPABASE_SERVICE_ROLE_KEY` | staging `sb_secret_…` |
| `OPENAI_API_KEY` | a staging-only OpenAI key; never reuse the production key |
| `SUPABASE_PROJECT_REF` | `<staging-ref>` |
| `SUPABASE_PROJECT_NAME` | `Clinical KB Staging` |
| `SUPABASE_STAGING_PROJECT_REF` | `<staging-ref>` |
| `SUPABASE_STAGING_PROJECT_NAME` | `Clinical KB Staging` |
| `RAG_QUERY_HASH_SECRET` | a staging secret |
| `RAG_PROVIDER_MODE` | `auto` |

The two `SUPABASE_STAGING_PROJECT_*` vars are what make the identity guard
accept the staging project. Setting `NEXT_PUBLIC_SUPABASE_URL` +
`SUPABASE_PROJECT_REF` to the staging ref **without** them will (correctly)
fail `check:supabase-project` — that's the deliberate speed bump.

3. **Host config:** bind `0.0.0.0:$PORT` (the Dockerfile already does),
health check `/api/health`, `min_machines_running=1`, no scale-to-zero.
3. **Service config:** the Dockerfile already binds `0.0.0.0:$PORT` (Railway
injects `$PORT`). Set the app service's healthcheck to `/api/health`, restart
policy to `ON_FAILURE`, and one replica pinned to `southeast-asia` with no
scale-to-zero (mirror `railway.app.json`).

4. **Worker (optional, for ingestion in staging):** build `Dockerfile.worker`
and run one instance with the same staging secrets. Required to process the
Expand All @@ -110,6 +115,8 @@ answer latency matters. No MCP is available here, so this is an operator step.
## What is operator-only (cannot be scripted here)

- Creating the Supabase project (billable) and its DB password.
- Opening the host account (e.g. Railway) and setting the runtime secrets.
- Setting the staging runtime secrets in the Railway environment (service
variables). Writing admin credentials to Railway is an operator/authorized
action.
- Any change to the **production** project's settings (e.g. auth
percentage-based connection allocation — see `docs/capacity-review.md` §3).
16 changes: 16 additions & 0 deletions railway.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"healthcheckPath": "/api/health/ready",
"restartPolicyType": "ON_FAILURE",
"multiRegionConfig": {
"asia-southeast1-eqsg3a": {
"numReplicas": 1
}
}
}
}
15 changes: 15 additions & 0 deletions railway.worker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile.worker"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"multiRegionConfig": {
"asia-southeast1-eqsg3a": {
"numReplicas": 1
}
}
}
}
10 changes: 10 additions & 0 deletions src/app/api/health/ready/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { healthResponse } from "@/lib/health-response";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Railway cannot attach the authenticated deep-probe header. This endpoint is
// intentionally limited to readiness state and exposes no diagnostic details.
export async function GET(request: Request) {
return healthResponse(request, { forceDeep: true, allowUnauthenticatedDeep: true, includeSlo: false });
}
Comment thread
BigSimmo marked this conversation as resolved.
60 changes: 2 additions & 58 deletions src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,8 @@
import { NextResponse } from "next/server";
import { env, isDemoMode } from "@/lib/env";
import type { AnswerSloSnapshot } from "@/lib/observability/answer-slo";
import { allowDeepHealthProbe } from "@/lib/deep-probe-auth";
import { healthResponse } from "@/lib/health-response";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
const deep = new URL(request.url).searchParams.get("deep") === "1";
const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY);

const checks: Record<string, "ok" | "missing" | "error" | "skipped" | "unauthorized"> = {
supabaseConfig: supabaseConfigured ? "ok" : "missing",
openaiConfig: env.OPENAI_API_KEY ? "ok" : "missing",
};

let slo: AnswerSloSnapshot | null = null;
if (deep) {
if (!allowDeepHealthProbe(request)) {
checks.supabase = "unauthorized";
} else if (supabaseConfigured && !isDemoMode()) {
try {
const [{ createAdminClient }, { probeSupabaseHealth }, { answerSloSnapshot }] = await Promise.all([
import("@/lib/supabase/admin"),
import("@/lib/supabase/health"),
import("@/lib/observability/answer-slo"),
]);
const admin = createAdminClient();
const health = await probeSupabaseHealth(admin);
checks.supabase = health.ok ? "ok" : "error";
if (health.ok) {
// Reliability telemetry only — a failure here must NOT flip liveness to
// 503, so it stays out of `checks` (which gates `ready`).
try {
slo = await answerSloSnapshot(admin);
} catch {
slo = null;
}
}
} catch {
checks.supabase = "error";
}
} else {
checks.supabase = "skipped";
}
}

const ready = !Object.values(checks).some(
(value) => value === "missing" || value === "error" || value === "unauthorized",
);

return NextResponse.json(
{
status: ready ? "ok" : "degraded",
demoMode: isDemoMode(),
timestamp: new Date().toISOString(),
uptimeSeconds: Math.round(process.uptime()),
checks,
...(slo ? { slo } : {}),
},
{ status: ready ? 200 : 503, headers: { "Cache-Control": "no-store" } },
);
return healthResponse(request);
}
11 changes: 11 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,17 @@ summary::-webkit-details-marker {
}
}

@media (min-width: 1024px) {
/* The document viewer is a two-column layout (main + 480px right rail). Keep
the floating composer within the main column so it never overlaps the
right-rail evidence/profile/tables text. Scoped to lg+, where the rail sits
beside the main column. */
.document-viewer-composer.floating-composer-edge {
left: max(2rem, var(--safe-area-left));
right: calc(max(0px, (100vw - 1440px) / 2) + 480px + 3.5rem + var(--safe-area-right));
Comment thread
BigSimmo marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
}
}

@media (max-width: 639px) {
.edge-glass-header {
padding-left: max(0px, var(--safe-area-left));
Expand Down
18 changes: 12 additions & 6 deletions src/components/AccessibleTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ function AccessibleTableMarkup({
<table
aria-label={caption ?? undefined}
className={cn(
"w-full border-separate border-spacing-0 text-left md:table-fixed",
// Non-dense tables use auto layout so columns size to their content
// (min-content = longest word) and wrap at word boundaries; a table
// too wide for its container scrolls via the overflow-x-auto wrapper
// above rather than squeezing columns until words break mid-character.
// The dense preview keeps a fixed layout (re-added below) for its
// ellipsised single-line cells.
"w-full border-separate border-spacing-0 text-left",
renderDensePreview ? "min-w-full table-fixed text-2xs" : expanded ? "text-base-minus" : "text-sm",
)}
>
Expand All @@ -135,7 +141,7 @@ function AccessibleTableMarkup({
"nums border-b border-[color:var(--border)] align-top font-semibold leading-5 text-[color:var(--text)]",
renderDensePreview
? "overflow-hidden text-ellipsis whitespace-nowrap"
: "whitespace-normal break-words [overflow-wrap:anywhere]",
: "whitespace-normal break-words",
index > 0 && "border-l border-[color:var(--border)]/70",
renderDensePreview
? "px-2 py-1.5 text-3xs uppercase tracking-[0.06em]"
Expand All @@ -152,7 +158,9 @@ function AccessibleTableMarkup({
scope="col"
className={cn(
"nums border-b border-l border-[color:var(--border)]/70 align-top font-semibold leading-5 text-[color:var(--text)]",
"whitespace-normal break-words [overflow-wrap:anywhere]",
renderDensePreview
? "overflow-hidden text-ellipsis whitespace-nowrap"
: "whitespace-normal break-words",
renderDensePreview
? "px-2 py-1.5 text-3xs uppercase tracking-[0.06em]"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: expanded
Expand Down Expand Up @@ -190,9 +198,7 @@ function AccessibleTableMarkup({
className={cn(
"nums align-top text-[color:var(--text)]",
renderDensePreview ? "table-cell" : "block md:table-cell",
renderDensePreview
? "overflow-hidden whitespace-nowrap"
: "whitespace-pre-wrap break-words [overflow-wrap:anywhere]",
renderDensePreview ? "overflow-hidden whitespace-nowrap" : "whitespace-pre-wrap break-words",
renderDensePreview
? "border-t border-[color:var(--border)]/70 px-2 py-1.5 leading-4"
: "border-b border-[color:var(--border)]/60 pb-2 last:border-b-0 md:border-b-0 md:border-t md:border-[color:var(--border)]/70 md:last:border-b-0",
Expand Down
Loading
Loading