diff --git a/.conductor/README.md b/.conductor/README.md new file mode 100644 index 00000000..7cf2de26 --- /dev/null +++ b/.conductor/README.md @@ -0,0 +1,81 @@ +# Conductor workspace setup + +This directory makes [Conductor](https://conductor.build) workspaces (git +worktrees) self-contained: each one installs its own deps and runs its own +backend + UI on a dedicated port range, so you can develop several branches at +once without collisions. + +## How it works + +Conductor gives every workspace a block of **10 ports** starting at +`$CONDUCTOR_PORT`. We use: + +| Port | Service | Script | +| --------------- | ------------------ | ----------------------- | +| `CONDUCTOR_PORT` | Backend (uvicorn) | `run-backend.sh` | +| `CONDUCTOR_PORT + 1` | UI (Next.js) | `run-ui.sh` | +| `+2 .. +9` | reserved | — | + +The UI is wired to its own workspace's backend (`NEXT_PUBLIC_BACKEND_URL`, +`BACKEND_URL`) and the backend's CORS / `UI_APP_URL` point back at its own UI — +all derived from `$CONDUCTOR_PORT`, so no two workspaces interfere. + +### Shared, NOT per-workspace + +Postgres, Redis, and MinIO run as a **single shared Docker stack** (compose +project `dograh`). `setup.sh` brings it up idempotently with +`COMPOSE_PROJECT_NAME=dograh` so every workspace reuses the same one instead of +fighting over the fixed 5432/6379/9000 ports. All workspaces therefore share one +database — fine for app servers, but it means the **Arq worker should run in only +one workspace** (`run-worker.sh`), since a single worker drains the shared queue +for everyone. + +## The Run menu + +Conductor shows three buttons (`run_mode = "concurrent"`, so they coexist): + +- **backend** (default) — FastAPI/uvicorn with `--reload` on `$CONDUCTOR_PORT` +- **ui** — Next.js dev server on `$CONDUCTOR_PORT + 1` +- **worker** — Arq worker; start in just one workspace + +Start **backend** and **ui** in each workspace you want to use. Start **worker** +once (e.g. in your primary workspace) when you need background jobs — the same +way you used to run a single arq worker in VS Code. + +## Proving you're in the right worktree + +`run-ui.sh` exports `NEXT_PUBLIC_WORKSPACE_NAME=$CONDUCTOR_WORKSPACE_NAME`, which +`ui/src/components/WorkspaceBadge.tsx` renders as a small color-coded pill in the +bottom-left corner (e.g. `⬡ pattaya :8201`). The color is derived from the +workspace name, so two worktrees are instantly distinguishable. The badge is +invisible in production and in a plain `npm run dev` (no env var set). + +## Creating a new workspace + +In Conductor: **New Workspace** → pick a branch. Conductor first copies the +gitignored env files listed in `../.worktreeinclude` (`api/.env`, `ui/.env`, +`ui/.env.local`, …) from your main checkout, then `setup.sh` runs automatically +and: + +1. checks out the `pipecat` submodule, +2. builds `venv` (Python 3.13) + installs backend/pipecat deps, +3. `npm install` for the UI, +4. ensures the shared Docker stack is up, +5. runs `alembic upgrade head`. + +The first setup is slow (deps); afterwards the Run buttons are instant. + +## Files + +| File | Purpose | +| --------------------- | -------------------------------------------------- | +| `settings.toml` | Conductor config: setup + run scripts, run_mode | +| `setup.sh` | One-time workspace bootstrap | +| `run-backend.sh` | Foreground uvicorn on `$CONDUCTOR_PORT` | +| `run-ui.sh` | Foreground Next.js on `$CONDUCTOR_PORT + 1` | +| `run-worker.sh` | Foreground Arq worker (run in one workspace only) | +| `../.worktreeinclude` | Gitignored env files Conductor copies per workspace | + +> Need machine-local tweaks (e.g. a different port base or skipping the worker)? +> Put them in `.conductor/settings.local.toml`, which is personal and not +> committed. diff --git a/.conductor/run-backend.sh b/.conductor/run-backend.sh new file mode 100755 index 00000000..160ed88a --- /dev/null +++ b/.conductor/run-backend.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Conductor run script — Backend (FastAPI/uvicorn) for THIS workspace. +# +# Binds $CONDUCTOR_PORT and points CORS / UI_APP_URL at this workspace's UI +# ($CONDUCTOR_PORT + 1). Runs in the FOREGROUND with exec (no &) so Conductor's +# SIGHUP cleanly tears it down. +set -euo pipefail +cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}" + +PORT="${CONDUCTOR_PORT:-8000}" +UI_PORT="$((PORT + 1))" + +# Load the workspace's api/.env, then override the port-specific bits. The +# backend reads config via os.getenv, and these exports happen after the source, +# so they win over the values copied from the main checkout (which assume 8000). +if [[ -f api/.env ]]; then set -a; # shellcheck disable=SC1091 + source api/.env; set +a; fi +export FASTAPI_PORT="$PORT" +export UI_APP_URL="http://localhost:${UI_PORT}" +export CORS_ALLOWED_ORIGINS="http://localhost:${UI_PORT},http://127.0.0.1:${UI_PORT}" + +if [[ ! -d venv ]]; then + echo "ERROR: venv missing. Re-run workspace setup (.conductor/setup.sh)." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +source venv/bin/activate + +echo "[backend] workspace=${CONDUCTOR_WORKSPACE_NAME:-?} port=${PORT} ui=${UI_PORT}" +exec uvicorn api.app:app --host 0.0.0.0 --port "$PORT" --reload --reload-dir api diff --git a/.conductor/run-ui.sh b/.conductor/run-ui.sh new file mode 100755 index 00000000..1fe3ca3d --- /dev/null +++ b/.conductor/run-ui.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Conductor run script — UI (Next.js) for THIS workspace. +# +# Binds $CONDUCTOR_PORT + 1, talks to this workspace's backend on $CONDUCTOR_PORT, +# and tags the build with the workspace identity (NEXT_PUBLIC_WORKSPACE_NAME) so +# the in-app WorkspaceBadge shows which worktree you're looking at. +# Runs in the FOREGROUND with exec (no &) so Conductor can stop it cleanly. +set -euo pipefail +cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}" + +PORT="${CONDUCTOR_PORT:-8000}" +UI_PORT="$((PORT + 1))" +BACKEND="http://localhost:${PORT}" + +# Ensure node is on PATH (load nvm + honor .nvmrc if needed). +if ! command -v node >/dev/null 2>&1; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + [[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh" + command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true +fi + +# Shell env overrides .env files in Next.js, so this points the UI at the right +# backend and stamps the workspace name into the client bundle. +export BACKEND_URL="$BACKEND" +export NEXT_PUBLIC_BACKEND_URL="$BACKEND" +export NEXT_PUBLIC_WORKSPACE_NAME="${CONDUCTOR_WORKSPACE_NAME:-local}" + +cd ui +echo "[ui] workspace=${NEXT_PUBLIC_WORKSPACE_NAME} port=${UI_PORT} backend=${BACKEND}" +exec npm run dev -- --port "$UI_PORT" diff --git a/.conductor/run-worker.sh b/.conductor/run-worker.sh new file mode 100755 index 00000000..d83ef222 --- /dev/null +++ b/.conductor/run-worker.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Conductor run script — Arq background worker. +# +# Run this in ONE workspace only. Every workspace shares the same Redis/Postgres, +# so a single arq worker drains the task queue for all of them — multiple workers +# would just fight over the same jobs. Foreground exec so Conductor stops it cleanly. +set -euo pipefail +cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}" + +if [[ -f api/.env ]]; then set -a; # shellcheck disable=SC1091 + source api/.env; set +a; fi + +if [[ ! -d venv ]]; then + echo "ERROR: venv missing. Re-run workspace setup (.conductor/setup.sh)." >&2 + exit 1 +fi +# shellcheck disable=SC1091 +source venv/bin/activate + +echo "[worker] arq worker on shared Redis — workspace=${CONDUCTOR_WORKSPACE_NAME:-?}" +exec python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG diff --git a/.conductor/settings.toml b/.conductor/settings.toml new file mode 100644 index 00000000..31afe775 --- /dev/null +++ b/.conductor/settings.toml @@ -0,0 +1,40 @@ +# Conductor project config — shared, committed so every contributor (and every +# new workspace) gets the same per-worktree dev workflow. +# +# Model: each Conductor workspace is its own git worktree and gets its own +# 10-port range starting at $CONDUCTOR_PORT. We use: +# $CONDUCTOR_PORT -> backend (FastAPI/uvicorn) +# $CONDUCTOR_PORT + 1 -> ui (Next.js) +# +2 .. +9 -> reserved for future per-workspace services +# +# Postgres/Redis/MinIO are a single SHARED Docker stack (project name "dograh"), +# not per-workspace — see .conductor/setup.sh and .conductor/README.md. +"$schema" = "https://conductor.build/schemas/settings.repo.schema.json" + +[scripts] +# Runs once when a workspace is created: copies gitignored env files, inits the +# pipecat submodule, builds the venv + node_modules, ensures the shared Docker +# stack, and runs migrations. +setup = "bash .conductor/setup.sh" + +# concurrent => multiple workspaces can run their dev servers at the same time +# (each on its own CONDUCTOR_PORT range), and within a workspace the backend + +# ui + worker run buttons don't kill each other. +run_mode = "concurrent" + +# Backend (FastAPI/uvicorn) on $CONDUCTOR_PORT. Default Run button. +[scripts.run.backend] +command = "bash .conductor/run-backend.sh" +icon = "server" +default = true + +# UI (Next.js) on $CONDUCTOR_PORT + 1, wired to this workspace's backend. +[scripts.run.ui] +command = "bash .conductor/run-ui.sh" +icon = "play" + +# Arq background worker. Start in ONE workspace only — all workspaces share the +# same Redis/Postgres, so a single worker drains the queue for everyone. +[scripts.run.worker] +command = "bash .conductor/run-worker.sh" +icon = "server" diff --git a/.conductor/setup.sh b/.conductor/setup.sh new file mode 100755 index 00000000..e340c47d --- /dev/null +++ b/.conductor/setup.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Conductor setup script — runs ONCE when a workspace (git worktree) is created. +# +# A fresh worktree only has git-tracked files, so this recreates the rest: the +# pipecat submodule checkout, a Python venv, ui/node_modules, the shared local +# Docker stack, and the DB schema. (Gitignored env files are copied separately +# by .worktreeinclude, BEFORE this script runs.) +# +# Conductor injects: CONDUCTOR_ROOT_PATH (main checkout), CONDUCTOR_WORKSPACE_PATH, +# CONDUCTOR_WORKSPACE_NAME, CONDUCTOR_PORT (first of 10), CONDUCTOR_IS_LOCAL. +set -euo pipefail + +ROOT="${CONDUCTOR_ROOT_PATH:-}" +WS="${CONDUCTOR_WORKSPACE_PATH:-$PWD}" +cd "$WS" + +log() { printf '\n\033[1;36m[conductor-setup]\033[0m %s\n' "$*"; } + +# 1) Env files — normally copied by .worktreeinclude BEFORE this runs (the +# Conductor-native file-copy mechanism). This block is only a FALLBACK so that +# `bash .conductor/setup.sh` also works when run by hand outside Conductor: it +# copies just the files that are still missing, and is a no-op under Conductor. +if [[ -n "$ROOT" && "$ROOT" != "$WS" ]]; then + for f in api/.env api/.env.test ui/.env ui/.env.local ui/.env.sentry-build-plugin; do + if [[ ! -f "$f" && -f "$ROOT/$f" ]]; then + mkdir -p "$(dirname "$f")" + cp "$ROOT/$f" "$f" + log "fallback-copied $f (not present from .worktreeinclude)" + fi + done +fi + +# 2) pipecat submodule — REQUIRED here, NOT redundant with step 3. +# A fresh git worktree has an empty pipecat/. setup_requirements.sh --dev +# (step 3) deliberately SKIPS `git submodule update` (it assumes CI already +# checked out submodules) but still runs `uv pip install -e ./pipecat`, which +# fails unless the checkout is already on disk. So we populate it first. +log "Initializing git submodules (pipecat)" +git submodule update --init --recursive + +# 3) Python venv with 3.13 + backend/pipecat deps ---------------------------- +# Bare `python3` may be 3.14 here; setup_requirements.sh requires 3.12/3.13. +PY313="$(command -v python3.13 || true)" +if [[ -z "$PY313" ]]; then + echo "ERROR: python3.13 not found. Install it (e.g. brew install python@3.13)." >&2 + exit 1 +fi +if [[ ! -d venv ]]; then + log "Creating venv with $PY313 ($("$PY313" --version))" + "$PY313" -m venv venv +fi +# shellcheck disable=SC1091 +source venv/bin/activate +log "Installing backend + pipecat deps (uv) — this is the slow step" +bash scripts/setup_requirements.sh --dev + +# 4) UI deps ----------------------------------------------------------------- +ensure_node() { + if ! command -v node >/dev/null 2>&1; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + [[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh" + command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true + fi +} +ensure_node +log "Installing UI deps (npm install)" +( cd ui && npm install ) + +# 5) Shared local Docker stack (idempotent, pinned project name) ------------- +# All workspaces share ONE stack named "dograh" so they never collide on the +# fixed Postgres/Redis/MinIO ports. If it's already up this is a no-op. +if command -v docker >/dev/null 2>&1; then + log "Ensuring shared Docker stack (COMPOSE_PROJECT_NAME=dograh)" + COMPOSE_PROJECT_NAME=dograh docker compose -f docker-compose-local.yaml up -d \ + || echo " (warning: could not start docker stack; start it manually)" +else + echo " (docker not found; start Postgres/Redis/MinIO yourself)" +fi + +# 6) DB migrations (best-effort; shared DB, alembic is idempotent) ----------- +if [[ -f api/.env ]]; then + log "Running DB migrations (alembic upgrade head)" + set -a; # shellcheck disable=SC1091 + source api/.env; set +a + alembic -c api/alembic.ini upgrade head || echo " (warning: migrations skipped/failed — is the DB up?)" +fi + +PORT="${CONDUCTOR_PORT:-8000}" +log "Setup complete for '${CONDUCTOR_WORKSPACE_NAME:-?}'." +echo " Run menu: backend -> :${PORT} ui -> :$((PORT + 1)) worker -> shared arq" diff --git a/.gitignore b/.gitignore index 3c8c2366..22d3026d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ __pycache__ .env.prod .env.test +# Conductor personal/per-machine overrides (settings.toml IS committed) +.conductor/settings.local.toml + # logs and run directory on production /logs/ /run/ diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 00000000..898da064 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,10 @@ +# Conductor: gitignored files copied from the main checkout into every new +# workspace (git worktree), BEFORE .conductor/setup.sh runs. These hold real +# secrets and are .gitignored, so a fresh worktree wouldn't otherwise have them. +# Patterns are gitignore-style. Adding this file overrides Conductor's default +# ".env*" copy, so list everything local dev needs explicitly. +api/.env +api/.env.test +ui/.env +ui/.env.local +ui/.env.sentry-build-plugin diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 15b4cfb5..2b621682 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -10,6 +10,7 @@ import PostHogIdentify from "@/components/PostHogIdentify"; import { SentryErrorBoundary } from "@/components/SentryErrorBoundary"; import SpinLoader from "@/components/SpinLoader"; import { ThemeProvider } from "@/components/ThemeProvider"; +import WorkspaceBadge from "@/components/WorkspaceBadge"; import { Toaster } from "@/components/ui/sonner"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { OnboardingProvider } from "@/context/OnboardingContext"; @@ -87,6 +88,7 @@ export default function RootLayout({ + ); diff --git a/ui/src/components/WorkspaceBadge.tsx b/ui/src/components/WorkspaceBadge.tsx new file mode 100644 index 00000000..bf7f61be --- /dev/null +++ b/ui/src/components/WorkspaceBadge.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Dev-only badge that proves which Conductor workspace this UI belongs to. + * + * Renders nothing unless NEXT_PUBLIC_WORKSPACE_NAME is set — which only happens + * when the UI is launched via .conductor/run-ui.sh. So production builds and a + * plain `npm run dev` are completely unaffected. + * + * The pill color is derived deterministically from the workspace name, so two + * worktrees running side by side are instantly distinguishable at a glance. + */ +export default function WorkspaceBadge() { + const workspace = process.env.NEXT_PUBLIC_WORKSPACE_NAME; + const backend = process.env.NEXT_PUBLIC_BACKEND_URL; + const [port, setPort] = useState(""); + + useEffect(() => { + setPort(window.location.port); + }, []); + + if (!workspace) return null; + + // Deterministic hue from the workspace name. + let hash = 0; + for (let i = 0; i < workspace.length; i++) { + hash = (hash * 31 + workspace.charCodeAt(i)) >>> 0; + } + const hue = hash % 360; + + return ( +
+ ⬡ {workspace} + {port ? ` :${port}` : ""} +
+ ); +}