diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5945b448..7570da5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,11 +83,9 @@ jobs: push: false load: true tags: omadia-web-ui:ci-${{ github.sha }} - # Same MIDDLEWARE_URL placeholder the production fly.toml uses; - # only baked into the standalone bundle, not used at runtime - # against this image. - build-args: | - MIDDLEWARE_URL=http://middleware:8080 + # No MIDDLEWARE_URL build-arg: it is a runtime setting since the + # /bot-api and /p proxies became route handlers + # (app/_lib/middlewareProxy.ts). cache-from: type=gha,scope=web-ui # See the middleware job: a cache-export error is non-fatal. cache-to: type=gha,scope=web-ui,mode=max,ignore-error=true diff --git a/.github/workflows/desktop-apps.yml b/.github/workflows/desktop-apps.yml index 638412bd..17b1f466 100644 --- a/.github/workflows/desktop-apps.yml +++ b/.github/workflows/desktop-apps.yml @@ -119,10 +119,12 @@ jobs: npm ci npm run build - - name: Build web-ui (bake the fixed kernel URL into the standalone build) + # No MIDDLEWARE_URL at build time: the /bot-api and /p proxies are route + # handlers that read it per request (web-ui/app/_lib/middlewareProxy.ts). + # The desktop supervisor injects the real kernel URL when it spawns the + # standalone server (desktop/src/supervisor.ts, uiEnv). + - name: Build web-ui working-directory: web-ui - env: - MIDDLEWARE_URL: ${{ env.KERNEL_URL }} run: | npm ci npm run build diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 7d079ec7..d16bcc83 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -66,8 +66,10 @@ jobs: image: ghcr.io/byte5ai/omadia-web-ui context: ./web-ui dockerfile: ./web-ui/Dockerfile - build_args: | - MIDDLEWARE_URL=http://middleware:8080 + # MIDDLEWARE_URL is a runtime setting since the /bot-api and /p + # proxies became route handlers (app/_lib/middlewareProxy.ts) — + # nothing middleware-related is baked at build time anymore. + build_args: '' steps: - uses: actions/checkout@v7 with: diff --git a/README.md b/README.md index c86c8559..ceb1b76d 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,28 @@ the differentiating logic, and verifying with the smoke runner before install. ## Deployment - **Local / single-tenant**: `docker compose up`, see Quickstart above +- **One-click cloud**: deploy the minimal core into your own Render + workspace — [`render.yaml`](render.yaml) provisions the middleware, + admin UI, and Postgres (pgvector), generates `VAULT_KEY`, and the + `/setup` wizard collects your LLM key on first boot. Runs on paid + instance types (the middleware needs a persistent disk). + + [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/byte5ai/omadia) + +- **One-command Fly.io**: Fly has no blueprint-style deploy button, so the + equivalent is one command. [`fly/deploy.sh`](fly/deploy.sh) provisions + three apps in your Fly org — middleware (persistent `/data` volume), + admin UI, and a private [`pgvector/pgvector`](https://hub.docker.com/r/pgvector/pgvector) + Postgres (the same image the compose stack uses; Fly's own Postgres + offerings either lack pgvector or gate it behind a dashboard toggle) — + generates `VAULT_KEY` and the database password, and deploys the GHCR + images. Needs a logged-in `flyctl`; roughly $10/month: + + ```bash + git clone https://github.com/byte5ai/omadia.git && cd omadia + ./fly/deploy.sh + ``` + - **Bring-your-own**: the runtime is a stock Node + Postgres app; any host that can run both works (Kubernetes, ECS, plain VM). diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index c551a6fc..38eda467 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -17,8 +17,6 @@ services: build: context: ./web-ui dockerfile: Dockerfile - args: - # Build-arg: the rewrite target baked into the Next.js build. Use the - # in-network service name so the browser-loaded UI reaches the - # middleware through Docker's DNS, not the host's localhost. - MIDDLEWARE_URL: http://middleware:8080 + # No MIDDLEWARE_URL build-arg: the /bot-api and /p proxies read it at + # request time (web-ui/app/_lib/middlewareProxy.ts), and the runtime + # value comes from the web-ui service env in docker-compose.yaml. diff --git a/fly/deploy.sh b/fly/deploy.sh new file mode 100755 index 00000000..8b02eb38 --- /dev/null +++ b/fly/deploy.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# One-command Fly.io deploy of the omadia minimal core — the Fly +# counterpart to the repo-root render.yaml blueprint. +# +# ./fly/deploy.sh +# +# Provisions three Fly apps in YOUR Fly org (your account, your bill): +# - omadia-postgres- pgvector/pgvector:pg17, private-only +# - omadia-middleware- kernel API, persistent /data volume +# - omadia-web-ui- admin UI, public entrypoint +# +# Secrets are generated here (VAULT_KEY, Postgres password); the LLM key +# is collected by the /setup wizard on first boot — nothing to paste. +# +# Prerequisites: flyctl installed and logged in (fly auth login), and +# openssl on PATH. Cost: three shared-cpu machines + two 1 GB volumes, +# roughly $10/month depending on usage. +# +# Overridable via environment: +# FLY_ORG Fly org slug (default: personal) +# FLY_REGION region for volumes/machines (default: fra — +# if you change it, also change primary_region in the +# three fly/*.fly.toml files) +# OMADIA_SUFFIX app-name suffix; Fly app names (default: random) +# are globally unique, hence one +# +# This is a one-shot installer, not an upgrade tool. To upgrade later: +# fly deploy --app --config fly/middleware.fly.toml +# fly deploy --app --config fly/web-ui.fly.toml +# To tear everything down: fly apps destroy . + +set -euo pipefail + +FLY="$(command -v fly || command -v flyctl || true)" +if [ -z "$FLY" ]; then + echo "flyctl not found — install it first: https://fly.io/docs/flyctl/install/" >&2 + exit 1 +fi + +ORG="${FLY_ORG:-personal}" +REGION="${FLY_REGION:-fra}" +SUFFIX="${OMADIA_SUFFIX:-$(openssl rand -hex 3)}" +PG_APP="omadia-postgres-${SUFFIX}" +MW_APP="omadia-middleware-${SUFFIX}" +UI_APP="omadia-web-ui-${SUFFIX}" + +cd "$(dirname "$0")" + +echo "==> Creating apps in org '${ORG}' (suffix: ${SUFFIX})" +"$FLY" apps create "$PG_APP" --org "$ORG" +"$FLY" apps create "$MW_APP" --org "$ORG" +"$FLY" apps create "$UI_APP" --org "$ORG" + +echo "==> Creating volumes in ${REGION}" +"$FLY" volumes create omadia_pgdata --app "$PG_APP" --region "$REGION" --size 1 --yes +"$FLY" volumes create omadia_data --app "$MW_APP" --region "$REGION" --size 1 --yes + +echo "==> Setting secrets" +PG_PASSWORD="$(openssl rand -hex 16)" +"$FLY" secrets set --app "$PG_APP" \ + POSTGRES_PASSWORD="$PG_PASSWORD" +"$FLY" secrets set --app "$MW_APP" \ + VAULT_KEY="$(openssl rand -base64 32)" \ + DATABASE_URL="postgresql://omadia:${PG_PASSWORD}@${PG_APP}.internal:5432/omadia" +"$FLY" secrets set --app "$UI_APP" \ + MIDDLEWARE_URL="http://${MW_APP}.internal:8080" + +echo "==> Deploying Postgres (${PG_APP})" +"$FLY" deploy --app "$PG_APP" --config pg.fly.toml --ha=false + +echo "==> Deploying middleware (${MW_APP}) — first boot runs migrations, allow a minute" +# If the middleware wins the race against initdb on the very first boot, +# its machine exits and Fly's restart policy retries until Postgres is up. +"$FLY" deploy --app "$MW_APP" --config middleware.fly.toml --ha=false + +echo "==> Deploying admin UI (${UI_APP})" +"$FLY" deploy --app "$UI_APP" --config web-ui.fly.toml --ha=false + +echo +echo "Done. Open https://${UI_APP}.fly.dev and finish the /setup wizard" +echo "(first admin + LLM key, stored encrypted in the vault)." +echo +echo "The middleware is public at https://${MW_APP}.fly.dev (needed for" +echo "channel webhooks). VAULT_KEY lives only as a Fly secret on ${MW_APP};" +echo "losing it makes vault entries unrecoverable." diff --git a/fly/middleware.fly.toml b/fly/middleware.fly.toml new file mode 100644 index 00000000..d7c24105 --- /dev/null +++ b/fly/middleware.fly.toml @@ -0,0 +1,50 @@ +# Fly config — omadia middleware (kernel API + plugin runtime). +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# Secrets set by deploy.sh: DATABASE_URL (points at the pg app over the +# private network), VAULT_KEY (generated; the GHCR image bakes +# NODE_ENV=production, which makes the key mandatory at boot — losing it +# makes vault entries unrecoverable). The LLM key is collected by the +# first-admin /setup wizard at runtime, so nothing else is required. +# +# DEV_ENDPOINTS_ENABLED stays unset on purpose: this app is publicly +# reachable, and the dev mounts expose raw KG + memory state without auth. + +primary_region = "fra" + +[build] + image = "ghcr.io/byte5ai/omadia-middleware:latest" + +[env] + # Persist vault, installed.json, builder drafts.db, and uploaded plugin + # packages on the volume below instead of the image layer. + PLATFORM_DATA_DIR = "/data" + +[mounts] + source = "omadia_data" + destination = "/data" + +[http_service] + internal_port = 8080 + force_https = true + # Keep the kernel always-on: cron routines and channel webhooks + # (Teams / Telegram) must not wait for a cold start. + auto_stop_machines = "off" + auto_start_machines = true + min_machines_running = 1 + + [[http_service.checks]] + interval = "10s" + timeout = "5s" + # First boot runs plugin-catalog load + KG migrations + bootstrap + # installs. Fly caps HTTP-check grace periods at 1 minute (longer + # values are lowered with a deploy warning), so this is the max — + # in practice the restart policy covers a slower first boot. + grace_period = "60s" + method = "GET" + path = "/health" + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "1gb" diff --git a/fly/pg.fly.toml b/fly/pg.fly.toml new file mode 100644 index 00000000..796b70ed --- /dev/null +++ b/fly/pg.fly.toml @@ -0,0 +1,37 @@ +# Fly config — Postgres + pgvector for the omadia minimal core. +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# Same image as docker-compose.yaml, so migrations behave identically. +# Fly's own Postgres offerings don't fit here: the unmanaged postgres-flex +# image ships without pgvector, and Managed Postgres gates the vector +# extension behind a dashboard toggle (and starts at a much higher price +# point). Running the pgvector image as a plain app keeps the stack +# scriptable end to end. +# +# No [http_service] / [[services]]: the database is reachable only over +# the org's private 6PN network at .internal:5432 — never public. +# Fly takes daily snapshots of the volume; for managed backups/HA, +# migrate to Fly Managed Postgres and enable the vector extension there. + +primary_region = "fra" + +[build] + image = "pgvector/pgvector:pg17" + +[env] + # Subdirectory below the mount: the official postgres image refuses to + # initdb into a directory that already contains lost+found (which every + # fresh Fly volume does). + PGDATA = "/var/lib/postgresql/data/pgdata" + POSTGRES_USER = "omadia" + POSTGRES_DB = "omadia" + # POSTGRES_PASSWORD comes in as a Fly secret (set by deploy.sh). + +[mounts] + source = "omadia_pgdata" + destination = "/var/lib/postgresql/data" + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "1gb" diff --git a/fly/web-ui.fly.toml b/fly/web-ui.fly.toml new file mode 100644 index 00000000..88fc12b4 --- /dev/null +++ b/fly/web-ui.fly.toml @@ -0,0 +1,25 @@ +# Fly config — omadia admin UI (Next.js). +# Deployed by fly/deploy.sh (app name is passed via --app). +# +# MIDDLEWARE_URL comes in as a Fly secret (set by deploy.sh) and points at +# the middleware app over the org's private 6PN network — the same +# topology as the compose stack's http://middleware:8080. + +primary_region = "fra" + +[build] + image = "ghcr.io/byte5ai/omadia-web-ui:latest" + +[http_service] + internal_port = 3000 + force_https = true + # The UI may scale to zero between visits; the proxy cold-starts it. + # The middleware underneath stays always-on. + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory = "512mb" diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..fe96b856 --- /dev/null +++ b/render.yaml @@ -0,0 +1,80 @@ +# Render Blueprint — one-click deploy of the omadia minimal core +# (middleware + admin UI + Postgres/pgvector) into the deployer's OWN +# Render workspace. This file backs the "Deploy to Render" button: +# +# https://render.com/deploy?repo=https://github.com/byte5ai/omadia +# +# Mirrors docker-compose.yaml's minimal core; the optional overlays +# (MinIO, Kroki, Ollama) are not part of the blueprint. +# +# No secrets to paste: VAULT_KEY is generated by Render on first sync +# (the GHCR image bakes NODE_ENV=production, which makes the key +# mandatory at boot — see README "Deployment"), and the LLM key is +# collected by the first-admin /setup wizard at runtime and stored +# encrypted in the vault. DEV_ENDPOINTS_ENABLED stays unset: these +# services are publicly reachable, and the dev mounts expose raw KG + +# memory state without auth. +# +# Costs: the middleware needs a persistent disk, which requires paid +# instance types — `starter` for both services plus `basic-256mb` +# Postgres. Everything is pinned to one region (frankfurt) so the +# database connection string resolves over the private network. + +services: + # --- Omadia middleware (TypeScript kernel + plugin runtime) --------------- + - type: web + name: omadia-middleware + runtime: image + image: + url: ghcr.io/byte5ai/omadia-middleware:latest + plan: starter + region: frankfurt + healthCheckPath: /health + envVars: + - key: DATABASE_URL + fromDatabase: + name: omadia-postgres + property: connectionString + # Vault master key. Generated once by Render; losing it makes vault + # entries unrecoverable, so never rotate it casually. Rotating LLM / + # plugin credentials happens inside the app (Admin → Runtime → + # Secrets), not by regenerating this key. + - key: VAULT_KEY + generateValue: true + # Persist vault, installed.json, builder drafts.db, and uploaded + # plugin packages on the disk below instead of the image layer. + - key: PLATFORM_DATA_DIR + value: /data + disk: + name: omadia-data + mountPath: /data + sizeGB: 1 + + # --- Admin UI (Next.js, talks to middleware over /bot-api) ---------------- + - type: web + name: omadia-web-ui + runtime: image + image: + url: ghcr.io/byte5ai/omadia-web-ui:latest + plan: starter + region: frankfurt + envVars: + # Server-side base URL the admin UI uses to reach the middleware. + # RENDER_EXTERNAL_URL is the middleware's public https URL, injected + # by Render — a full URL with scheme, which MIDDLEWARE_URL requires. + - key: MIDDLEWARE_URL + fromService: + name: omadia-middleware + type: web + envVarKey: RENDER_EXTERNAL_URL + +databases: + # --- Postgres + pgvector (knowledge graph, routines, verifier store) ----- + # Migrations run automatically on the middleware's first boot, including + # CREATE EXTENSION IF NOT EXISTS vector (supported on Render Postgres). + - name: omadia-postgres + plan: basic-256mb + region: frankfurt + postgresMajorVersion: "17" + databaseName: omadia + user: omadia diff --git a/web-ui/Dockerfile b/web-ui/Dockerfile index e50b62bc..dce95b19 100644 --- a/web-ui/Dockerfile +++ b/web-ui/Dockerfile @@ -17,12 +17,11 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # `output: 'standalone'` (next.config.ts) emits .next/standalone + .next/static ENV NEXT_TELEMETRY_DISABLED=1 -# Next.js bakes the /bot-api/* rewrite destination into routes-manifest.json -# at build time, so MIDDLEWARE_URL has to be known here — not at runtime. -# Override from fly.toml [build.args] or `fly deploy --build-arg` when moving -# the middleware onto a different internal hostname. -ARG MIDDLEWARE_URL=http://middleware:8080 -ENV MIDDLEWARE_URL=${MIDDLEWARE_URL} +# MIDDLEWARE_URL is a pure RUNTIME setting: /bot-api/* and /p/* are proxied +# by route handlers that read it per request (app/_lib/middlewareProxy.ts), +# so the same image runs against any middleware host. Do not reintroduce a +# build ARG for it — that was the bug that pinned the published image to +# the compose hostname. RUN npm run build # --- runtime ---------------------------------------------------------------- diff --git a/web-ui/app/_components/RuntimeReadinessBanner.tsx b/web-ui/app/_components/RuntimeReadinessBanner.tsx new file mode 100644 index 00000000..11d0758d --- /dev/null +++ b/web-ui/app/_components/RuntimeReadinessBanner.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { motion } from 'framer-motion'; +import { KeyRound } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +/** + * RuntimeReadinessBanner — turns the fresh-install "everything 503s" state + * into a visible, actionable hint. + * + * On a fresh install the orchestrator plugin has no LLM API key, so it never + * publishes chatAgent@1 / orchestratorRegistry@1: every operator surface + * (agents, channels, skills, chat) answers 503 + * `multi_orchestrator_unavailable`, and routines aren't mounted at all. The + * individual pages then surface raw "GET … failed: 503" strings with no hint + * at the cause. This card names the cause and links to the fix. + * + * Detection is a probe of one representative operator route, looking for the + * structured 503. It re-probes on tab focus, and on a heartbeat while the + * card is visible, so it clears itself the moment the key is saved (the + * operator routes go live without a restart). + * + * Mounted once in the root layout, next to SessionWatcher. Renders nothing + * on /login + /setup. + */ + +/** Heartbeat cadence while the card is visible — catches the key being saved. */ +const HEARTBEAT_MS = 60 * 1000; + +function isAuthPage(pathname: string): boolean { + return pathname === '/login' || pathname === '/setup'; +} + +export function RuntimeReadinessBanner(): React.ReactElement | null { + const pathname = usePathname(); + const onAuthPage = isAuthPage(pathname); + + const [unavailable, setUnavailable] = useState(false); + const [dismissed, setDismissed] = useState(false); + + // ── Initial probe + focus re-check + heartbeat-while-visible ──────────── + // One effect à la SessionWatcher: the probe lives inside so every + // setState happens after an await (no sync-setState-in-effect). The + // heartbeat is only armed while the card shows — its job is to clear the + // card once the key lands and the operator routes come up. + useEffect(() => { + if (onAuthPage) return; + let cancelled = false; + + const probe = async (): Promise => { + try { + const res = await fetch('/bot-api/v1/operator/agents', { + credentials: 'include', + }); + if (cancelled) return; + if (res.status !== 503) { + // 200 = runtime is up; 401/403 = not this card's concern. + setUnavailable(false); + return; + } + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + if (cancelled) return; + setUnavailable(body?.error === 'multi_orchestrator_unavailable'); + } catch { + // Network blip — leave state intact; the next probe retries. + } + }; + + void probe(); + const heartbeat = + unavailable && !dismissed + ? window.setInterval(() => void probe(), HEARTBEAT_MS) + : undefined; + const onVisibility = (): void => { + if (document.visibilityState === 'visible') void probe(); + }; + document.addEventListener('visibilitychange', onVisibility); + + return () => { + cancelled = true; + if (heartbeat !== undefined) window.clearInterval(heartbeat); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [onAuthPage, unavailable, dismissed]); + + if (onAuthPage || !unavailable || dismissed) return null; + + // No AnimatePresence exit animation on purpose: the card leaves when the + // runtime comes up — an instant disappearance is fine, and it keeps the + // clear-on-heartbeat path deterministic under fake timers in tests. + return setDismissed(true)} />; +} + +/** Non-blocking bottom-right card, styled after SessionWarningCard. */ +function ReadinessCard({ + onDismiss, +}: { + onDismiss: () => void; +}): React.ReactElement { + const t = useTranslations('runtimeReadiness'); + + return ( + +
+ + {t('title')} +
+

+ {t('body')} +

+
+ + {t('cta')} + + +
+
+ ); +} diff --git a/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx b/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx index 76ef92c7..2ab7ee4e 100644 --- a/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx +++ b/web-ui/app/_components/__tests__/LocaleSwitcher.test.tsx @@ -11,8 +11,10 @@ vi.mock('next/navigation', () => ({ /** * Regression guard for issue #360 — same rationale as ThemeControls.test.tsx: * the Windows native combobox widget silently ignores per-`