diff --git a/Makefile b/Makefile index e483aa2..887a37c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run +.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env PYTHON ?= python3 @@ -184,6 +184,23 @@ register-mcp: bootstrap-env: scripts/bootstrap-env.sh $(ARGS) +## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration. +## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub +## Fleet tunnel when reachable: make dev-hub PROFILE=fleet +dev-hub: + bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev) + +## Local edge relay for offline write buffering (STATE-WP-0068). +edge-relay: + @fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true + STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \ + $(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080 + +## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status +mcp-profile: + @test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1) + bash scripts/mcp_hub_profile.sh "$(PROFILE)" + ## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra add-repo: @test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1) diff --git a/custodian_cli.py b/custodian_cli.py index 3d12f2c..de88e2b 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -565,6 +565,27 @@ def cmd_outbox_cancel(args: argparse.Namespace) -> None: store.cancel(args.envelope_id) print(f"Cancelled {args.envelope_id}") + +def cmd_dev_up(args: argparse.Namespace) -> None: + """Start files-first local dev hub (compose path via dev_hub_up.sh).""" + script = STATE_HUB_DIR / "scripts" / "dev_hub_up.sh" + if not script.exists(): + print(f"ERROR: dev hub script not found at {script}") + sys.exit(1) + + env = os.environ.copy() + if args.with_edge: + env["WITH_EDGE"] = "1" + if args.with_mcp: + env["WITH_MCP"] = "1" + if args.repo_root: + env["REPO_ROOT"] = str(Path(args.repo_root).expanduser().resolve()) + + cmd = ["bash", str(script), args.profile] + result = subprocess.run(cmd, env=env, cwd=STATE_HUB_DIR) + sys.exit(result.returncode) + + # ── Entry point ──────────────────────────────────────────────────────────────── def main() -> None: @@ -709,6 +730,25 @@ def main() -> None: # status sub.add_parser("status", help="Show State Hub health and summary totals") + # dev up — files-first local hub (CUST-WP-0054-T07) + dev = sub.add_parser("dev", help="Local dev-hub commands") + dev_sub = dev.add_subparsers(dest="dev_command", required=True) + dev_up = dev_sub.add_parser("up", help="Start local dev hub from repo files") + dev_up.add_argument( + "--profile", + choices=["dev", "fleet"], + default="dev", + help="dev=local :8000; fleet=prefer tunnel :18000 when reachable", + ) + dev_up.add_argument("--with-edge", action="store_true", help="Start edge relay on :18080") + dev_up.add_argument("--with-mcp", action="store_true", help="Register dev-hub MCP after API is up") + dev_up.add_argument( + "--repo-root", + default=None, + help="Parent directory for register-from-classification-all (default: $HOME)", + ) + dev_up.set_defaults(func=cmd_dev_up) + args = parser.parse_args() if hasattr(args, "func"): diff --git a/scripts/dev_hub_up.sh b/scripts/dev_hub_up.sh new file mode 100755 index 0000000..d9b38bc --- /dev/null +++ b/scripts/dev_hub_up.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Start a files-first local dev-hub: postgres, migrate, seed, API, optional edge relay. +set -euo pipefail + +STATE_HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$STATE_HUB_DIR" + +PROFILE="${1:-dev}" +WITH_EDGE="${WITH_EDGE:-0}" +WITH_MCP="${WITH_MCP:-0}" +REPO_ROOT="${REPO_ROOT:-$HOME}" + +usage() { + cat <<'USAGE' +Usage: scripts/dev_hub_up.sh [dev|fleet] + + dev — local ephemeral hub on :8000 (default) + fleet — prefer fleet tunnel API on :18000 when reachable + +Environment: + WITH_EDGE=1 Start edge relay on :18080 after API is up + WITH_MCP=1 Register dev-hub MCP after API is up + REPO_ROOT= Parent directory for register-from-classification-all (default: $HOME) +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +api_healthy() { + curl -fsS --max-time 2 "${1%/}/state/health" >/dev/null 2>&1 +} + +pick_api_base() { + if [[ "$PROFILE" == "fleet" ]]; then + if api_healthy "http://127.0.0.1:18000"; then + echo "http://127.0.0.1:18000" + return + fi + echo "WARN: fleet profile requested but :18000 unreachable; falling back to local :8000" >&2 + fi + echo "http://127.0.0.1:8000" +} + +API_BASE="$(pick_api_base)" +export API_BASE + +echo "==> Dev hub profile: ${PROFILE}" +echo "==> API_BASE: ${API_BASE}" + +if [[ "$API_BASE" == "http://127.0.0.1:8000" ]]; then + echo "==> Starting local postgres + migrations" + make db + make migrate + make seed + + if api_healthy "$API_BASE"; then + echo "==> API already running on :8000" + else + echo "==> Starting API on :8000 (background)" + fuser -k 8000/tcp 2>/dev/null || true + nohup "$(command -v uv || echo uv)" run uvicorn api.main:app \ + --host 127.0.0.1 --port 8000 \ + >"${STATE_HUB_DIR}/.dev-hub-api.log" 2>&1 & + for _ in $(seq 1 30); do + api_healthy "$API_BASE" && break + sleep 1 + done + api_healthy "$API_BASE" || { + echo "ERROR: API failed to start; see .dev-hub-api.log" >&2 + exit 1 + } + echo "==> API healthy" + fi + + echo "==> Registering local repos from classification files under ${REPO_ROOT}" + API_BASE="$API_BASE" make register-from-classification-all || { + echo "WARN: register-from-classification-all had errors (continuing)" >&2 + } +else + echo "==> Fleet profile: using remote hub; skipping local postgres/migrate/seed" +fi + +if [[ "$WITH_EDGE" == "1" && "$API_BASE" == "http://127.0.0.1:8000" ]]; then + echo "==> Starting edge relay on :18080" + fuser -k 18080/tcp 2>/dev/null || true + STATEHUB_UPSTREAM_URL="$API_BASE" nohup "$(command -v uv || echo uv)" run uvicorn api.edge.relay:app \ + --host 127.0.0.1 --port 18080 \ + >"${STATE_HUB_DIR}/.dev-hub-edge.log" 2>&1 & + echo " Edge relay: http://127.0.0.1:18080 (set API_BASE to this for offline buffering)" +fi + +if [[ "$WITH_MCP" == "1" ]]; then + make register-mcp API_BASE="$API_BASE" || echo "WARN: MCP registration skipped" >&2 +fi + +echo "" +echo "Dev hub ready." +echo " API: ${API_BASE}" +echo " Profile: ${PROFILE}" +echo " Replay: statehub outbox replay --upstream-url ${API_BASE}" +echo " Consistency: statehub fix-consistency --repo --api-base ${API_BASE}" \ No newline at end of file diff --git a/scripts/mcp_hub_profile.sh b/scripts/mcp_hub_profile.sh new file mode 100755 index 0000000..0a856e1 --- /dev/null +++ b/scripts/mcp_hub_profile.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Switch State Hub MCP/API profile between local dev and fleet tunnel. +set -euo pipefail + +PROFILE="${1:-}" +CLAUDE_JSON="${CLAUDE_JSON:-$HOME/.claude.json}" +STATE_HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: scripts/mcp_hub_profile.sh + + dev — local dev-hub API :8000, MCP :8001 + fleet — fleet tunnel API :18000, MCP :18001 + status — show detected profile and URLs +USAGE +} + +api_healthy() { + curl -fsS --max-time 2 "${1%/}/state/health" >/dev/null 2>&1 +} + +detect_profile() { + if api_healthy "http://127.0.0.1:18000"; then + echo fleet + elif api_healthy "http://127.0.0.1:8000"; then + echo dev + else + echo unknown + fi +} + +profile_urls() { + case "$1" in + dev) + API_BASE="http://127.0.0.1:8000" + MCP_URL="http://127.0.0.1:8001/sse" + ;; + fleet) + API_BASE="http://127.0.0.1:18000" + MCP_URL="http://127.0.0.1:18001/sse" + ;; + *) + echo "ERROR: unknown profile $1" >&2 + exit 2 + ;; + esac +} + +if [[ -z "$PROFILE" || "$PROFILE" == "-h" || "$PROFILE" == "--help" ]]; then + usage + exit 0 +fi + +if [[ "$PROFILE" == "status" ]]; then + current="$(detect_profile)" + echo "detected_profile=${current}" + if [[ "$current" != "unknown" ]]; then + profile_urls "$current" + echo "API_BASE=${API_BASE}" + echo "MCP_URL=${MCP_URL}" + fi + exit 0 +fi + +profile_urls "$PROFILE" +echo "==> Switching to profile: ${PROFILE}" +echo " API_BASE=${API_BASE}" +echo " MCP_URL=${MCP_URL}" + +if api_healthy "$API_BASE"; then + echo "OK: API reachable at ${API_BASE}/state/health" +else + echo "WARN: API not reachable at ${API_BASE}/state/health" +fi + +if command -v claude >/dev/null 2>&1; then + CONFIG="$(python3 -c "import json; print(json.dumps({'type':'sse','url':'${MCP_URL}'}))")" + claude mcp add-json -s user dev-hub "$CONFIG" 2>/dev/null || true + echo "OK: registered dev-hub MCP → ${MCP_URL}" +else + echo "WARN: claude CLI not found; set MCP manually to ${MCP_URL}" +fi + +profile_file="${STATE_HUB_DIR}/.hub-profile" +printf 'API_BASE=%s\nMCP_URL=%s\nPROFILE=%s\n' "$API_BASE" "$MCP_URL" "$PROFILE" >"$profile_file" +echo "OK: wrote ${profile_file}" +echo "Export: export API_BASE=${API_BASE}" \ No newline at end of file