chore: add Conductor per-worktree dev setup (.conductor/ + .worktreeinclude)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek Kumar 2026-06-29 12:48:33 +05:30
parent 0a6f7673b5
commit c099dad03d
10 changed files with 352 additions and 0 deletions

81
.conductor/README.md Normal file
View file

@ -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.

30
.conductor/run-backend.sh Executable file
View file

@ -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

31
.conductor/run-ui.sh Executable file
View file

@ -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"

21
.conductor/run-worker.sh Executable file
View file

@ -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

40
.conductor/settings.toml Normal file
View file

@ -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"

91
.conductor/setup.sh Executable file
View file

@ -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"

3
.gitignore vendored
View file

@ -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/

10
.worktreeinclude Normal file
View file

@ -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

View file

@ -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({
</AuthProvider>
</SentryErrorBoundary>
</ThemeProvider>
<WorkspaceBadge />
</body>
</html>
);

View file

@ -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 (
<div
className="pointer-events-none fixed bottom-2 left-2 z-[9999] select-none rounded-full px-2.5 py-1 font-mono text-[11px] font-medium text-white shadow-md"
style={{ backgroundColor: `hsl(${hue} 70% 38% / 0.92)` }}
title={`Conductor workspace: ${workspace}\nUI port: ${port}\nBackend: ${backend ?? "?"}`}
>
{workspace}
{port ? ` :${port}` : ""}
</div>
);
}