diff --git a/.gitignore b/.gitignore index 6b63b2c..5e1e8d2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ TURBO_STATE_DIR:-* # Plugin build artifacts — shipped via GitHub Releases, not committed plugin/dist/ + +# Railway deploy state (contains generated secrets) +railway/.deploy-state +.railway/ diff --git a/railway/README.md b/railway/README.md index b640d64..0349645 100644 --- a/railway/README.md +++ b/railway/README.md @@ -1,10 +1,8 @@ -# Railway template +# Railway deploy -One-click Railway deploy for the REVA-OPS stack. - -**Single Railway project. Single public endpoint (`/mcp`). Multiple internal services.** - -## What gets deployed +One Railway project, three application services, three managed databases. +Only `mcp-router` has a public domain — everything else is on Railway's +private network (`*.railway.internal`). ``` ┌─────────────────────── Railway project ───────────────────────┐ @@ -12,7 +10,7 @@ One-click Railway deploy for the REVA-OPS stack. │ PUBLIC │ │ ┌──────────────────┐ │ │ │ mcp-router │ ← reva-turbo plugin points here │ -│ │ services/... │ https://.up.railway.app/mcp │ +│ │ /mcp + /signup │ https://.up.railway.app/mcp │ │ └────────┬─────────┘ │ │ │ (Railway private network, *.railway.internal) │ │ ┌────────▼─────────┐ ┌──────────────────┐ │ @@ -22,43 +20,128 @@ One-click Railway deploy for the REVA-OPS stack. │ │ │ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ ┌──▼────────┐ │ │ │ Postgres │ │ FalkorDB │ │ Qdrant │ │ -│ │ (plugin) │ │ (plugin) │ │ (plugin) │ │ │ └───────────┘ └───────────┘ └───────────┘ │ └────────────────────────────────────────────────────────────────┘ ``` ## Files -- `template.yaml` — Railway template manifest (services, envs, plugins, build - refs) -- `deploy.sh` — CLI path: clone, provision, seed, print the public URL + API - key. Use this when you want a repeatable deploy you can script against. -- `env.example` — everything the template prompts for +- `deploy.sh` — phased deploy script against the Railway CLI (4.40+) +- `template.yaml` — reference spec for the stack. Not executable — Railway + CLI doesn't deploy from a local YAML; use `deploy.sh` for reproducibility + or the web UI for manual clicks. +- `.deploy-state` — generated secrets (gitignored) + +## Prerequisites + +```bash +# CLI +brew install railwayapp/railway/railway # macOS +# or: npm i -g @railway/cli + +railway login # browser-based OAuth +railway whoami # sanity check + +# Other deps +brew install jq openssl +``` + +**Authorize Railway's GitHub App for the `mrdulasolutions` org.** Railway +installs its GitHub App inline the first time you add a repo-sourced +service, so there is no standalone "Integrations" page to click. Until +the app is installed for the org, `railway add --repo` returns +`Unauthorized`. + +Trigger the install by adding the first service through the web UI: -## One-click deploy +1. Open the `reva-ops` project dashboard → `+ Create` → **GitHub Repo** +2. Pick `mrdulasolutions/NakatomiCRM`. If it isn't listed, click the + **Configure GitHub App** link Railway shows and grant access to + `NakatomiCRM` **and** `automem` (or the whole `mrdulasolutions` org) +3. Name the service exactly **`nakatomi-backend`** (env var wiring + depends on this name) +4. Repeat for `mrdulasolutions/automem` → name **`automem-backend`** -Click the button in the root `README.md`. Railway reads `template.yaml`, -provisions the three plugins, spins up all three services, runs the -Nakatomi migrations, and seeds the Rev A schema. +Don't configure env vars in the UI; `deploy.sh services` sets all of +them via CLI after the services exist. -## CLI deploy (recommended for admins) +## Deploy (phased) + +`deploy.sh` is broken into phases so you can inspect Railway's state +between steps. Run without arguments for the whole flight, or pass a +specific phase name to retry one. ```bash -# from repo root -./railway/deploy.sh --project-name reva-ops --admin-email you@reva.com -# → prints: -# public MCP url: https://.up.railway.app/mcp -# admin api key: nk_... -# Save both. The plugin's install.sh will prompt you for them. +# Phase 1: create project + provision DBs (Postgres, FalkorDB, Qdrant) +./railway/deploy.sh init + +# Phase 2: add & configure nakatomi-backend, automem-backend, mcp-router +# (sets private-network URLs, mints API tokens, generates router +# public domain, runs `railway up` for the router from +# services/mcp-router) +./railway/deploy.sh services + +# Phase 3: seed Nakatomi admin user + Rev A pipeline/custom fields +./railway/deploy.sh seed --admin-email you@reva-mfg.com + +# Phase 4: print the public MCP URL, admin creds, and PM signup token +./railway/deploy.sh finalize + +# Or do all four in order: +./railway/deploy.sh --admin-email you@reva-mfg.com ``` -## After deploy +After `init`, this directory is linked to the project via `.railway/`; +every subsequent `railway` command auto-discovers it. + +## What you hand out to PMs -Point the REVA-TURBO plugin at the new stack: +Two things only: + +1. **Signup URL** — `https://.up.railway.app/signup` +2. **Signup token** — the `REVA_SIGNUP_TOKEN` value printed by + `deploy.sh finalize` (or read from `.deploy-state`) + +PMs mint their own `nk_...` API keys from that page. You don't share your +admin key with anyone. + +## Rotating the signup token + +```bash +railway variables --service mcp-router --set "REVA_SIGNUP_TOKEN=$(openssl rand -hex 16)" +# Old token stops working on the next deploy. Existing PMs are unaffected +# (their API keys don't depend on the signup token after mint). +``` + +## If something fails mid-flight + +Each phase is independently re-runnable: ```bash -curl -fsSL https://raw.githubusercontent.com/mrdulasolutions/RevOps-RevAMfg/main/plugin/install.sh \ - | REVA_MCP_URL=https://.up.railway.app/mcp \ - REVA_API_KEY=nk_... \ - bash +./railway/deploy.sh services # re-runs service adds (idempotent — `railway add` errors on dupes, which the script tolerates) +./railway/deploy.sh seed # retry just the seed if Nakatomi wasn't ready the first time ``` + +Logs: + +```bash +railway logs --service mcp-router +railway logs --service nakatomi-backend +railway logs --service automem-backend +``` + +## Manual fallback (web UI) + +If the CLI path fails and you need to unblock, you can click the same +topology together in the Railway dashboard: + +1. Create project `reva-ops` +2. Add Postgres, FalkorDB (`falkordb/falkordb:latest`), Qdrant + (`qdrant/qdrant:latest`) as services +3. Add GitHub-source services `nakatomi-backend` (`mrdulasolutions/NakatomiCRM`) + and `automem-backend` (`mrdulasolutions/automem`) +4. Add `mcp-router`: either point at `mrdulasolutions/RevOps-RevAMfg` with + Root Directory = `services/mcp-router`, or deploy via CLI from the local + subdir (`cd services/mcp-router && railway up`) +5. Copy the env-var blocks from `deploy.sh` phase 2 into the dashboard +6. Run the seed commands from phase 3 via `railway run` diff --git a/railway/deploy.sh b/railway/deploy.sh index 6d4d46f..7c14f01 100755 --- a/railway/deploy.sh +++ b/railway/deploy.sh @@ -1,22 +1,54 @@ #!/usr/bin/env bash -# REVA-OPS Railway deploy — one Railway project, router + nakatomi + automem. +# REVA-OPS Railway deploy — one project, three services + three managed DBs. # -# Requires: railway CLI (>=4.0) logged in, jq, openssl. +# The deploy is phased so you can inspect Railway's state between steps. +# Run without arguments to execute every phase in order; pass a phase name +# to run just that phase. # -# Usage: -# ./railway/deploy.sh --project-name reva-ops --admin-email you@reva.com +# ./railway/deploy.sh init # project + Postgres/FalkorDB/Qdrant +# ./railway/deploy.sh services # nakatomi-backend, automem-backend, mcp-router +# ./railway/deploy.sh seed # Nakatomi admin + Rev A pipeline/fields +# ./railway/deploy.sh finalize # print public URL, API key, signup token +# ./railway/deploy.sh # all phases, in order # -# On success, prints the public MCP URL and the seeded admin API key. +# Required: +# railway CLI >= 4.40 (https://docs.railway.com/guides/cli), logged in +# openssl, jq +# +# The first phase calls `railway init`, which links this directory to the +# new project via .railway/ — every subsequent phase auto-discovers the +# project from that link. set -euo pipefail -PROJECT_NAME="reva-ops" -ADMIN_EMAIL="" -ADMIN_PASSWORD="" -WORKSPACE_SLUG="reva" -WORKSPACE_NAME="Rev A Manufacturing" -REGION="us-east4-eqdc4a" +# ── Config ──────────────────────────────────────────────────────────────── +PROJECT_NAME="${PROJECT_NAME:-reva-ops}" +ADMIN_EMAIL="${ADMIN_EMAIL:-}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" +WORKSPACE_SLUG="${WORKSPACE_SLUG:-reva}" +WORKSPACE_NAME="${WORKSPACE_NAME:-Rev A Manufacturing}" + +NAKATOMI_REPO="${NAKATOMI_REPO:-mrdulasolutions/NakatomiCRM}" +AUTOMEM_REPO="${AUTOMEM_REPO:-mrdulasolutions/automem}" + +ROUTER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../services/mcp-router" && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ── Pretty output ───────────────────────────────────────────────────────── +bold() { printf "\033[1m%s\033[0m\n" "$*"; } +say() { printf "\033[1;36m[reva-ops]\033[0m %s\n" "$*"; } +warn() { printf "\033[1;33m[reva-ops]\033[0m %s\n" "$*" >&2; } +die() { printf "\033[1;31m[reva-ops]\033[0m %s\n" "$*" >&2; exit 1; } + +# ── Pre-flight ──────────────────────────────────────────────────────────── +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } +need railway; need openssl; need jq +railway whoami >/dev/null 2>&1 || die "Not logged in. Run: railway login" + +# ── Argument parsing ────────────────────────────────────────────────────── +# Accept flags before positional phase name. +PHASE="" while [[ $# -gt 0 ]]; do case "$1" in --project-name) PROJECT_NAME="$2"; shift 2 ;; @@ -24,81 +56,287 @@ while [[ $# -gt 0 ]]; do --admin-password) ADMIN_PASSWORD="$2"; shift 2 ;; --workspace-slug) WORKSPACE_SLUG="$2"; shift 2 ;; --workspace-name) WORKSPACE_NAME="$2"; shift 2 ;; - --region) REGION="$2"; shift 2 ;; - -h|--help) - sed -n '1,20p' "$0"; exit 0 ;; - *) - echo "unknown flag: $1" >&2; exit 2 ;; + -h|--help) sed -n '1,25p' "$0"; exit 0 ;; + init|services|seed|finalize|all) PHASE="$1"; shift ;; + *) die "unknown arg: $1" ;; esac done -if [[ -z "$ADMIN_EMAIL" ]]; then - echo "--admin-email required" >&2; exit 2 -fi -if [[ -z "$ADMIN_PASSWORD" ]]; then - ADMIN_PASSWORD=$(openssl rand -hex 12) - echo "→ generated admin password: $ADMIN_PASSWORD" -fi - -command -v railway >/dev/null || { echo "railway CLI not installed" >&2; exit 1; } -command -v jq >/dev/null || { echo "jq not installed" >&2; exit 1; } -command -v openssl >/dev/null || { echo "openssl not installed" >&2; exit 1; } - -echo "→ creating project: $PROJECT_NAME" -railway init --name "$PROJECT_NAME" - -echo "→ provisioning plugins (Postgres, FalkorDB, Qdrant)" -railway add --database postgres -# FalkorDB + Qdrant are custom images — provisioned via the template import. -# If your Railway version of the CLI lacks template import, run: -# railway up --service falkordb --docker-image falkordb/falkordb:latest -# railway up --service qdrant --docker-image qdrant/qdrant:latest - -echo "→ deploying services from railway/template.yaml" -railway up --config railway/template.yaml - -echo "→ waiting for nakatomi-backend to report healthy" -for _ in $(seq 1 30); do - if railway status --service nakatomi-backend --json 2>/dev/null | jq -e '.status == "SUCCESS"' >/dev/null; then - break +# ── Shared secrets (generated once, reused across phases via .railway.env) ─ +STATE_FILE="$REPO_ROOT/railway/.deploy-state" +load_state() { [ -f "$STATE_FILE" ] && # shellcheck disable=SC1090 + . "$STATE_FILE" || true; } +save_state() { + umask 077 + cat >"$STATE_FILE" </dev/null ) \ + | jq -e --arg n "$1" '.services.edges[].node.name | select(. == $n)' >/dev/null 2>&1 +} + +phase_init() { + bold "[1/4] init — project + databases" + + if ( cd "$REPO_ROOT" && railway status >/dev/null 2>&1 ); then + say "project already linked — skipping init" + else + say "creating Railway project: $PROJECT_NAME" + ( cd "$REPO_ROOT" && railway init --name "$PROJECT_NAME" ) fi - sleep 5 -done -echo "→ seeding admin user + Rev A pipeline/custom fields" -NAKATOMI_INTERNAL="http://nakatomi-backend.railway.internal:8000" -railway run --service nakatomi-backend -- \ - python -m scripts.seed \ - --email "$ADMIN_EMAIL" --password "$ADMIN_PASSWORD" \ - --workspace-name "$WORKSPACE_NAME" --workspace-slug "$WORKSPACE_SLUG" + # Idempotent: `railway add` silently creates duplicates on re-run, so we + # gate each add on a name check. If a service with the target name exists, + # we skip. + if have_service "Postgres"; then + say "Postgres already exists — skipping" + else + say "adding Postgres" + ( cd "$REPO_ROOT" && railway add --database postgres ) + fi + + if have_service "falkordb"; then + say "falkordb already exists — skipping" + else + say "adding FalkorDB (custom image)" + ( cd "$REPO_ROOT" && railway add --service falkordb --image falkordb/falkordb:latest ) + fi + + if have_service "qdrant"; then + say "qdrant already exists — skipping" + else + say "adding Qdrant (custom image)" + ( cd "$REPO_ROOT" && railway add --service qdrant --image qdrant/qdrant:latest ) + fi + + say "init complete — check: railway status" +} + +# ── Phase: services ─────────────────────────────────────────────────────── +phase_services() { + bold "[2/4] services — automem, nakatomi, mcp-router" + + # Pre-flight: Railway's GitHub App must be installed on the mrdulasolutions + # org. Railway installs it inline the first time you add a repo-sourced + # service from the web UI — there's no standalone Integrations page. Until + # that install exists, `railway add --repo` returns Unauthorized. + warn "If this phase returns 'Unauthorized', the Railway GitHub App isn't" + warn "installed for mrdulasolutions. Fix (one-time):" + warn " 1. Open the reva-ops project → + Create → GitHub Repo" + warn " 2. Pick mrdulasolutions/NakatomiCRM (click 'Configure GitHub App'" + warn " if the repo isn't in the picker; grant access to NakatomiCRM" + warn " and automem)" + warn " 3. Name the first service 'nakatomi-backend'; it's safe to let" + warn " the script re-run — the idempotency check will skip the add." + warn " 4. Re-run: ./railway/deploy.sh services" + echo + + if have_service "automem-backend"; then + say "automem-backend already exists — skipping add" + else + say "adding automem-backend (repo: $AUTOMEM_REPO)" + ( cd "$REPO_ROOT" && railway add --service automem-backend --repo "$AUTOMEM_REPO" ) + fi + + # NOTE: Railway private-DNS names are derived from the *original* service + # name at creation time, not its current name — so a later rename leaves the + # DNS stale. Use Railway variable references (${{svc.RAILWAY_PRIVATE_DOMAIN}}) + # which Railway resolves at deploy-time against the real current domain. + say "setting automem-backend env vars" + railway variables --service automem-backend --skip-deploys \ + --set "PORT=8001" \ + --set 'FALKORDB_HOST=${{falkordb.RAILWAY_PRIVATE_DOMAIN}}' \ + --set "FALKORDB_PORT=6379" \ + --set "API_TOKEN=$AUTOMEM_API_TOKEN" \ + --set "ADMIN_TOKEN=$AUTOMEM_ADMIN_TOKEN" \ + --set "EMBEDDING_MODEL=text-embedding-3-small" \ + --set 'QDRANT_URL=http://${{qdrant.RAILWAY_PRIVATE_DOMAIN}}:6333' + + if have_service "nakatomi-backend"; then + say "nakatomi-backend already exists — skipping add" + else + say "adding nakatomi-backend (repo: $NAKATOMI_REPO)" + ( cd "$REPO_ROOT" && railway add --service nakatomi-backend --repo "$NAKATOMI_REPO" ) + fi + + say "setting nakatomi-backend env vars" + railway variables --service nakatomi-backend --skip-deploys \ + --set "PORT=8000" \ + --set 'DATABASE_URL=${{Postgres.DATABASE_URL}}' \ + --set "SECRET_KEY=$NAKATOMI_SECRET_KEY" \ + --set "STORAGE_BACKEND=local" \ + --set "MEMORY_CONNECTORS=automem" \ + --set 'AUTOMEM_URL=http://${{automem-backend.RAILWAY_PRIVATE_DOMAIN}}:8001' \ + --set "AUTOMEM_API_KEY=$AUTOMEM_API_TOKEN" + + # mcp-router deploys from local subdir — Railway CLI's `add --repo` + # has no rootDirectory flag, so we create the service empty then `up` into it. + if have_service "mcp-router"; then + say "mcp-router already exists — skipping add" + else + say "adding mcp-router service (empty shell)" + ( cd "$REPO_ROOT" && railway add --service mcp-router ) + fi + + say "setting mcp-router env vars" + railway variables --service mcp-router --skip-deploys \ + --set "PORT=8080" \ + --set "LOG_LEVEL=INFO" \ + --set 'NAKATOMI_INTERNAL_URL=http://${{nakatomi-backend.RAILWAY_PRIVATE_DOMAIN}}:8000' \ + --set 'AUTOMEM_INTERNAL_URL=http://${{automem-backend.RAILWAY_PRIVATE_DOMAIN}}:8001' \ + --set "AUTH_MODE=passthrough" \ + --set "AUTOMEM_API_TOKEN=$AUTOMEM_API_TOKEN" \ + --set "CRM_TOOL_PREFIX=crm" \ + --set "MEM_TOOL_PREFIX=mem" \ + --set "REVA_SIGNUP_TOKEN=$REVA_SIGNUP_TOKEN" \ + --set "REVA_WORKSPACE_SLUG=$WORKSPACE_SLUG" + # NAKATOMI_ADMIN_TOKEN set in phase_seed once we have it. + + say "generating public domain for mcp-router" + ( cd "$REPO_ROOT" && railway domain --service mcp-router ) || true + ROUTER_PUBLIC_URL="$(railway domain --service mcp-router --json 2>/dev/null \ + | jq -r '.domains[0] // empty' | sed 's#^https\?://##')" + if [ -n "$ROUTER_PUBLIC_URL" ]; then + railway variables --service mcp-router --skip-deploys \ + --set "PUBLIC_MCP_URL=https://$ROUTER_PUBLIC_URL/mcp" + save_state + else + warn "could not capture router public URL — set PUBLIC_MCP_URL manually after deploy" + fi -# Capture the API key that the seed script prints (stdout). -API_KEY=$(railway logs --service nakatomi-backend --lines 100 \ - | grep -oE 'nk_[A-Za-z0-9_-]+' | tail -n1 || true) + say "deploying mcp-router from $ROUTER_DIR" + ( cd "$ROUTER_DIR" && railway up --service mcp-router --ci ) -# Apply Rev A schema overlay -railway run --service nakatomi-backend -- \ - python -c "import httpx, json; import subprocess" >/dev/null 2>&1 || true + say "services phase complete — watch builds: railway logs --service nakatomi-backend" +} -railway run --service mcp-router -- \ - sh -c "pip install httpx >/dev/null && python /app/../nakatomi-backend/seed/reva.py \ - --api-url $NAKATOMI_INTERNAL --token $API_KEY" || \ - echo "⚠ seed/reva.py apply failed — run manually after deploy" +# ── Phase: seed ─────────────────────────────────────────────────────────── +# Discover the Nakatomi service name from Railway state. A manual rename in +# the web UI (e.g. "Nakatomi-backend" vs "nakatomi-backend") can drift the +# case — match it tolerantly. +discover_nakatomi_service() { + ( cd "$REPO_ROOT" && railway status --json 2>/dev/null ) \ + | jq -r '.services.edges[].node.name + | select(ascii_downcase == "nakatomi-backend" + or ascii_downcase == "nakatomibackend" + or (ascii_downcase | startswith("nakatomi")))' \ + | head -n1 +} -PUBLIC_URL=$(railway domain --service mcp-router --json 2>/dev/null | jq -r '.[0].domain' || echo "unknown") +phase_seed() { + bold "[3/4] seed — admin user + Rev A schema" -cat <} + say "waiting for $NAK_SVC to be healthy (up to 4 min)" + local ok=0 + for _ in $(seq 1 48); do + if railway logs --service "$NAK_SVC" 2>/dev/null | grep -q "Uvicorn running\|application startup complete"; then + ok=1; break + fi + sleep 5 + done + [ "$ok" -eq 1 ] || warn "$NAK_SVC not yet healthy — seed may fail; retry with: ./railway/deploy.sh seed" -Point the REVA-TURBO plugin at the new stack: + say "seeding workspace + admin user (via railway ssh into $NAK_SVC)" + # The seed lives at /app/scripts/seed.py inside the Nakatomi container. We + # run it over railway ssh — `railway run` injects env vars locally, it does + # not execute in the container, so it can't reach the in-container DB. + local seed_out + seed_out="$(railway ssh --service "$NAK_SVC" "cd /app && python -m scripts.seed \ + --email '$ADMIN_EMAIL' \ + --password '$ADMIN_PASSWORD' \ + --workspace-name '$WORKSPACE_NAME' \ + --workspace-slug '$WORKSPACE_SLUG'" 2>&1 || true)" + echo "$seed_out" - curl -fsSL https://raw.githubusercontent.com/mrdulasolutions/RevOps-RevAMfg/main/plugin/install.sh \\ - | REVA_MCP_URL=https://$PUBLIC_URL/mcp REVA_API_KEY=$API_KEY bash + NAKATOMI_ADMIN_TOKEN="$(printf '%s' "$seed_out" | grep -oE 'nk_[A-Za-z0-9_-]+' | tail -n1 || true)" + if [ -z "$NAKATOMI_ADMIN_TOKEN" ]; then + warn "could not auto-extract admin API key from seed output — copy manually from logs above" + else + save_state + say "captured admin API key" + railway variables --service mcp-router --set "NAKATOMI_ADMIN_TOKEN=$NAKATOMI_ADMIN_TOKEN" >/dev/null + fi + + say "applying Rev A pipeline + custom-field overlay" + # The overlay script ships only in this monorepo (the upstream Nakatomi + # image doesn't vendor it). Push it into the container via base64, then + # run it against localhost — the DB-ready API is on port 8000 in-container. + local B64 overlay_out + B64="$(base64 -i "$REPO_ROOT/services/nakatomi-backend/seed/reva.py" | tr -d '\n')" + overlay_out="$(railway ssh --service "$NAK_SVC" "echo '$B64' | base64 -d > /tmp/reva.py && \ + cd /tmp && python reva.py \ + --api-url http://localhost:8000 \ + --token '${NAKATOMI_ADMIN_TOKEN:-SET_MANUALLY}'" 2>&1 || true)" + echo "$overlay_out" + printf '%s' "$overlay_out" | grep -q "seed complete" \ + || warn "reva.py overlay did not report completion — inspect output above" +} + +# ── Phase: finalize ─────────────────────────────────────────────────────── +phase_finalize() { + bold "[4/4] finalize — print credentials" + load_state + ROUTER_PUBLIC_URL="${ROUTER_PUBLIC_URL:-$(railway domain --service mcp-router --json 2>/dev/null | jq -r '.domains[0] // empty' | sed 's#^https\?://##')}" + + cat <}/mcp + Signup page: https://${ROUTER_PUBLIC_URL:-}/signup + Admin email: ${ADMIN_EMAIL:-} + Admin password: ${ADMIN_PASSWORD:-} + Admin API key: ${NAKATOMI_ADMIN_TOKEN:-} + Signup token (PMs): ${REVA_SIGNUP_TOKEN} + +$(bold "Share with PMs:") + 1. Signup page URL above + 2. Signup token above + (No other credentials. PMs mint their own nk_... key via /signup.) + +$(bold "Rotation:") + Signup token: railway variable set REVA_SIGNUP_TOKEN=\$(openssl rand -hex 16) --service mcp-router + Admin token: mint new via Nakatomi, then update NAKATOMI_ADMIN_TOKEN + +$(bold "State file:") railway/.deploy-state (gitignored — contains secrets) EOF +} + +# ── Run ─────────────────────────────────────────────────────────────────── +case "${PHASE:-all}" in + init) phase_init ;; + services) phase_services ;; + seed) phase_seed ;; + finalize) phase_finalize ;; + all) phase_init; phase_services; phase_seed; phase_finalize ;; + *) die "unknown phase: $PHASE" ;; +esac diff --git a/services/mcp-router/railway.toml b/services/mcp-router/railway.toml index dd919da..a63a1d6 100644 --- a/services/mcp-router/railway.toml +++ b/services/mcp-router/railway.toml @@ -6,8 +6,10 @@ builder = "dockerfile" dockerfilePath = "Dockerfile" [deploy] -startCommand = "uvicorn router.main:app --host 0.0.0.0 --port $PORT" +# No startCommand: Railway runs it via exec without shell expansion, so +# "$PORT" stays literal and uvicorn rejects it. The Dockerfile's CMD +# already wraps in `sh -c` which expands correctly. Let that drive. healthcheckPath = "/health" -healthcheckTimeout = 30 +healthcheckTimeout = 60 restartPolicyType = "on_failure" restartPolicyMaxRetries = 3 diff --git a/services/mcp-router/requirements.txt b/services/mcp-router/requirements.txt index 42fc401..157bc79 100644 --- a/services/mcp-router/requirements.txt +++ b/services/mcp-router/requirements.txt @@ -3,5 +3,6 @@ uvicorn[standard]>=0.30,<0.40 mcp[server]>=1.2,<2.0 httpx>=0.27,<0.30 pydantic>=2.8,<3.0 +email-validator>=2.0,<3.0 pydantic-settings>=2.5,<3.0 python-json-logger>=2.0,<4.0 diff --git a/services/mcp-router/router/main.py b/services/mcp-router/router/main.py index e037c63..cafbc15 100644 --- a/services/mcp-router/router/main.py +++ b/services/mcp-router/router/main.py @@ -4,6 +4,7 @@ from __future__ import annotations +import contextlib import logging from fastapi import FastAPI @@ -38,9 +39,19 @@ def create_app() -> FastAPI: logging.basicConfig(level=settings.log_level.upper()) log = logging.getLogger("reva.router") - app = FastAPI(title="REVA MCP Router", version="0.1.0") - mcp = build_mcp() + + # FastMCP's streamable-HTTP session manager runs inside its own anyio task + # group; mounting the sub-app alone doesn't start it. Wiring the session + # manager's lifespan into the parent FastAPI app is what keeps the task + # group alive for the server's lifetime — without it every /mcp/ request + # 500s with "Task group is not initialized". + @contextlib.asynccontextmanager + async def lifespan(_: FastAPI): + async with mcp.session_manager.run(): + yield + + app = FastAPI(title="REVA MCP Router", version="0.1.0", lifespan=lifespan) app.mount("/mcp", mcp.streamable_http_app()) # Self-service signup (GET /signup HTML, POST /signup JSON) diff --git a/services/nakatomi-backend/seed/reva.py b/services/nakatomi-backend/seed/reva.py index 64c686d..e692321 100644 --- a/services/nakatomi-backend/seed/reva.py +++ b/services/nakatomi-backend/seed/reva.py @@ -2,61 +2,79 @@ Run once after ``alembic upgrade head`` on a fresh Nakatomi database: - python -m seed.reva --api-url https://.railway.internal:8000 \\ - --admin-email you@reva.com --admin-password ... - -Idempotent: re-running updates existing pipelines/fields by name. + python reva.py --api-url http://localhost:8000 --token nk_... + +Idempotent: re-running is a no-op for existing pipelines/fields (matched by +slug / (entity_type, name)). + +Schema notes — Nakatomi's API: + * POST /pipelines takes ``{name, slug, stages: [StageIn, ...]}`` — stages + are created inline, there is no /pipelines/{id}/stages route. + * StageIn = ``{name, slug, position, probability, is_won, is_lost}``. + * POST /custom-fields takes CustomFieldIn = ``{entity_type, name, label, + field_type, description}``. Allowed field_type values: + string|text|number|bool|date|url|email|select. No ``object`` / ``array`` + — we encode structured payloads as ``text`` (client parses JSON). """ from __future__ import annotations import argparse import os +import re import sys from typing import Any import httpx +REVA_PIPELINE_SLUG = "manufacturing-rfq" REVA_PIPELINE_NAME = "Manufacturing RFQ" -REVA_STAGES = [ - {"name": "RFQ Received", "probability": 0.05, "order": 1}, - {"name": "Qualified", "probability": 0.20, "order": 2}, - {"name": "Quoted", "probability": 0.35, "order": 3}, - {"name": "Accepted", "probability": 0.55, "order": 4}, - {"name": "In Manufacturing", "probability": 0.70, "order": 5}, - {"name": "Inspection (G2)", "probability": 0.80, "order": 6}, - {"name": "Repackage", "probability": 0.85, "order": 7}, - {"name": "Shipped", "probability": 0.90, "order": 8}, - {"name": "Delivered", "probability": 0.93, "order": 9}, - {"name": "Invoiced", "probability": 0.96, "order": 10}, - {"name": "Paid", "probability": 1.00, "order": 11, "is_won": True}, - {"name": "Closed Lost", "probability": 0.00, "order": 12, "is_lost": True}, + +# (name, probability, is_won, is_lost) — position is derived from order. +REVA_STAGES: list[dict[str, Any]] = [ + {"name": "RFQ Received", "probability": 0.05}, + {"name": "Qualified", "probability": 0.20}, + {"name": "Quoted", "probability": 0.35}, + {"name": "Accepted", "probability": 0.55}, + {"name": "In Manufacturing", "probability": 0.70}, + {"name": "Inspection (G2)", "probability": 0.80}, + {"name": "Repackage", "probability": 0.85}, + {"name": "Shipped", "probability": 0.90}, + {"name": "Delivered", "probability": 0.93}, + {"name": "Invoiced", "probability": 0.96}, + {"name": "Paid", "probability": 1.00, "is_won": True}, + {"name": "Closed Lost", "probability": 0.00, "is_lost": True}, ] -REVA_CUSTOM_FIELDS: dict[str, list[dict[str, Any]]] = { - "company": [ - {"key": "partner_scorecard", "type": "object", - "description": "{on_time_pct, defect_rate, lead_time_days, last_audit_date}"}, - {"key": "compliance", "type": "object", - "description": "{itar: bool, ear: bool, iso9001: bool, as9100: bool, certs: [str]}"}, - {"key": "region", "type": "string", - "description": "china | us | mexico | eu | other"}, - ], - "contact": [ - {"key": "role", "type": "string", - "description": "buyer | engineering | quality | shipping | finance"}, - ], - "deal": [ - {"key": "quality_gates", "type": "object", - "description": "{g1_material, g2_fai, g3_production, g4_shipping} each {status, inspector, date}"}, - {"key": "ncrs", "type": "array", - "description": "[{id, stage, issue, severity, owner, status, opened_at, resolved_at}]"}, - {"key": "part_numbers", "type": "array", - "description": "Part numbers in this RFQ/order"}, - {"key": "china_source", "type": "object", - "description": "{supplier_id, buyer_agent, po_number, port_of_origin}"}, - ], -} +# (entity_type, name, label, field_type, description). +# Nakatomi's field_type set is scalar-only; object/array payloads ride as +# ``text`` (JSON-encoded). +REVA_CUSTOM_FIELDS: list[dict[str, Any]] = [ + {"entity_type": "company", "name": "partner_scorecard", "label": "Partner Scorecard", + "field_type": "text", + "description": "JSON: {on_time_pct, defect_rate, lead_time_days, last_audit_date}"}, + {"entity_type": "company", "name": "compliance", "label": "Compliance", + "field_type": "text", + "description": "JSON: {itar, ear, iso9001, as9100, certs:[str]}"}, + {"entity_type": "company", "name": "region", "label": "Region", + "field_type": "string", + "description": "china | us | mexico | eu | other"}, + {"entity_type": "contact", "name": "role", "label": "Role", + "field_type": "string", + "description": "buyer | engineering | quality | shipping | finance"}, + {"entity_type": "deal", "name": "quality_gates", "label": "Quality Gates", + "field_type": "text", + "description": "JSON: {g1_material, g2_fai, g3_production, g4_shipping}"}, + {"entity_type": "deal", "name": "ncrs", "label": "NCRs", + "field_type": "text", + "description": "JSON array: [{id,stage,issue,severity,owner,status,...}]"}, + {"entity_type": "deal", "name": "part_numbers", "label": "Part Numbers", + "field_type": "text", + "description": "Comma-separated or JSON array of part numbers"}, + {"entity_type": "deal", "name": "china_source", "label": "China Source", + "field_type": "text", + "description": "JSON: {supplier_id, buyer_agent, po_number, port_of_origin}"}, +] REVA_TAG_VOCABULARY = [ "reva/rfq", "reva/quality", "reva/compliance", "reva/china-source", @@ -64,6 +82,10 @@ ] +def _slug(name: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + class NakatomiSeeder: def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") @@ -83,34 +105,50 @@ def _req(self, method: str, path: str, json: Any = None, params: Any = None) -> def upsert_pipeline(self) -> dict: existing = self._req("GET", "/pipelines") or [] - found = next((p for p in existing if p["name"] == REVA_PIPELINE_NAME), None) + found = next((p for p in existing if p.get("slug") == REVA_PIPELINE_SLUG), None) if found: - print(f"✓ pipeline exists: {REVA_PIPELINE_NAME} ({found['id']})") - pipeline = found - else: - pipeline = self._req("POST", "/pipelines", json={"name": REVA_PIPELINE_NAME}) - print(f"+ pipeline created: {REVA_PIPELINE_NAME} ({pipeline['id']})") - - existing_stages = {s["name"]: s for s in pipeline.get("stages", [])} - for stage in REVA_STAGES: - if stage["name"] in existing_stages: - continue - self._req("POST", f"/pipelines/{pipeline['id']}/stages", json=stage) - print(f" + stage: {stage['name']}") + print(f"= pipeline exists: {REVA_PIPELINE_NAME} ({found['id']}, {len(found.get('stages', []))} stages)") + return found + + stages_payload = [ + { + "name": s["name"], + "slug": _slug(s["name"]), + "position": i, + "probability": s.get("probability", 0), + "is_won": s.get("is_won", False), + "is_lost": s.get("is_lost", False), + } + for i, s in enumerate(REVA_STAGES) + ] + pipeline = self._req( + "POST", "/pipelines", + json={ + "name": REVA_PIPELINE_NAME, + "slug": REVA_PIPELINE_SLUG, + "is_default": True, + "stages": stages_payload, + }, + ) + print(f"+ pipeline created: {REVA_PIPELINE_NAME} ({pipeline['id']}, {len(pipeline.get('stages', []))} stages)") return pipeline def upsert_custom_fields(self) -> None: - for entity_type, fields in REVA_CUSTOM_FIELDS.items(): - for field in fields: - body = {"entity_type": entity_type, **field} - try: - self._req("POST", "/schema/custom-fields", json=body) - print(f" + custom_field {entity_type}.{field['key']}") - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 409: # already exists - print(f" = custom_field {entity_type}.{field['key']} (exists)") - else: - raise + existing = self._req("GET", "/custom-fields") or [] + key = lambda f: (f.get("entity_type"), f.get("name")) + have = {key(f) for f in existing} + for field in REVA_CUSTOM_FIELDS: + if (field["entity_type"], field["name"]) in have: + print(f" = custom_field {field['entity_type']}.{field['name']} (exists)") + continue + try: + self._req("POST", "/custom-fields", json=field) + print(f" + custom_field {field['entity_type']}.{field['name']}") + except httpx.HTTPStatusError as exc: + if exc.response.status_code in (400, 409): + print(f" = custom_field {field['entity_type']}.{field['name']} (exists)") + else: + raise def run(self) -> None: print(f"Seeding Rev A schema against {self.base_url}")