- Notifications
You must be signed in to change notification settings - Fork 0
Railway-only deployment docs + document-viewer UX cleanup#511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
ca37957c60bf695c9a02b7428b4ad7fb7eca67d2a1c7c1dc314e3b6f631f0a396af45257c2e587cca301File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| 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. | ||
| @@ -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 . | ||
| ``` | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 | ||
| @@ -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). | ||
| 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 | ||
| } | ||
| } | ||
| } | ||
| } |
| 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 | ||
| } | ||
| } | ||
| } | ||
| } |
| 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 }); | ||
| } | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. BigSimmo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| } | ||
| @media (max-width: 639px) { | ||
| .edge-glass-header { | ||
| padding-left: max(0px, var(--safe-area-left)); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| )} | ||
| > | ||
| @@ -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]" | ||
| @@ -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]" | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| : expanded | ||
| @@ -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", | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the staging setup runbook, this starts a literal
```dotenvmarker 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 aftersupabase db pushand keep the health note as normal text.Useful? React with 👍 / 👎.