Files
cleveragents-core/tools/launch_prod.sh
T
drew eb01eb0172 feat(controller): dual-mode launcher (fork/prod) + DB-mode validator
Adds the operator surface for switching the controller pipeline between
the personal fork (drew/cleveragents-core) and the canonical repo
(cleveragents/cleveragents-core) via a MODE env + --prod CLI flag,
backed by safety primitives that make a wrong-mode launch loud rather
than silent.

run-controller-state-machine-pipeline.sh: --prod flag and MODE env
(primary home: .devcontainer/.env) select fork vs prod. After resolving
MODE, the launcher auto-sources the matching overlay file
(.devcontainer/.env.{fork,prod}) and asserts MODE didn't drift during
the source step. The drift assertion uses a readonly snapshot under an
obscure variable name so a stray ``MODE=fork`` in .env.prod aborts the
launch with a clear bash error rather than silently demoting the run.
CONTROLLER_RUN_DIR_ROOT now overrides the trial /tmp path so prod can
use a persistent /var/lib/cleveragents/run dir.

tools/launch_prod.sh (new): sibling to launch_fork.sh with the opposite
safety primitive — affirmative GET /repos/{owner}/{repo} that asserts
the target is non-fork, exists, isn't archived, and the bot has push.
On any failure, no env is exported. Honors HAL_* aliases for parity
with launch_fork.sh and prints a hard-to-miss PROD-MODE banner.

tools/controller/deploy/validate_db_mode.py (new): stamps a _mode_marker
table on each SQLite db (controller DB + telemetry cache) on first use,
asserts a match on every subsequent launch, and moves mismatched files
aside as <name>.<prior-mode>.bak.<ts> — never deletes. The --adopt flag
lets an operator grandfather in already-good pre-marker data without
losing history. Wired into the launcher's startup sequence before
OpenCode and the master start.

tools/_cache_path.py (new): single source of truth for the per-(owner,
repo) Forgejo cache file convention. .opencode/telemetry/server.py and
the launcher both delegate here so the dual-source-truth drift risk is
eliminated. tools/_pipeline_cache.py and tools/controller/db/models.py
documented as not owning the _mode_marker table so future migrations
leave it alone.

.opencode/telemetry/server.py: hosts the llm_activity scraper as a
background subprocess thread (60s cadence, --since-hours 1 in steady
state, full backfill on first tick). Re-homes the cost-telemetry data
path after the pr_state_warmer was retired by the controller migration
— without this the Cost tab freezes when the warmer's loop is gone.
Subprocess (not in-process) for isolation; failures swallowed.

opencode.json: local-claude provider's baseURL now reads
{env:LOCAL_PROXY_URL} instead of the literal http://127.0.0.1:3456/v1,
matching the apiKey pattern already in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:25:24 -04:00

429 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/launch_prod.sh — Pin PROD env for the auto-agents controller.
#
# Sibling to tools/launch_fork.sh. Same shape; OPPOSITE safety primitive:
# validates that the target is the CANONICAL repo (a non-fork the caller
# has push on) and refuses to launch if it isn't.
#
# Designed to be SOURCED, either directly or via the controller launcher's
# --prod flag:
#
# source tools/launch_prod.sh
# tools/run-controller-state-machine-pipeline.sh --prod
#
# What gets exported (same names as launch_fork.sh — drop-in)
# -----------------------------------------------------------
# Required by the auto-agents env contract:
# GIT_USER_NAME — git user.name for HAL9000's commits
# GIT_USER_EMAIL — git user.email for HAL9000's commits
# FORGEJO_PAT — HAL9000 PAT (driver + LLM workers)
# FORGEJO_USERNAME — HAL9000 login (resolved via whoami if absent)
# FORGEJO_PASSWORD — HAL9000 web password (CI-log scraping)
# FORGEJO_REVIEWER_PAT — HAL9001 PAT (umbrella reviewer)
# FORGEJO_REVIEWER_USERNAME — HAL9001 login
# FORGEJO_REVIEWER_PASSWORD — HAL9001 web password
#
# Driver-side compat:
# GITEA_TOKEN — synthesized from FORGEJO_PAT (HAL9000) by
# default; pre-export to override.
#
# Forgejo target:
# FORGEJO_URL — base URL (default: https://git.cleverthis.com)
# FORGEJO_OWNER — canonical owner (default: cleveragents)
# FORGEJO_REPO — canonical repo (default: cleveragents-core)
# FORGEJO_API_BASE — derived from FORGEJO_URL
# FORGEJO_DEFAULT_BRANCH — default: master
#
# Controller convenience:
# CONTROLLER_OWNER / CONTROLLER_REPO — set so the launcher's
# OWNER="${CONTROLLER_OWNER:-${FORK_OWNER:-drew}}" resolution
# picks them up without further plumbing.
# CLEVERAGENTS_LAUNCH_MODE=prod — mode marker for downstream code.
#
# Production-leaning defaults (overridable):
# MERGE_DRIVER_LOG_LEVEL=INFO (vs DEBUG in fork mode)
# IMPLEMENTER_ESTIMATOR_ENABLED=1
# REVIEW_DISPATCHER_USE_PYTHON_FILTERS=1
#
# Resolution priority for each name (parity with launch_fork.sh):
# 1. value already in the environment
# 2. canonical name in .devcontainer/.env.prod (preferred), then
# .env.prod (repo root), then .devcontainer/.env, then .env
# 3. HAL_* alias (HAL_9000_API_KEY, HAL_9000_FORGEJO_PASSWORD,
# HAL_9001_API_KEY, HAL_9001_FORGEJO_PASSWORD) in those same files
#
# The PROD safety primitive is the affirmative repo validation below
# (target MUST be non-fork + bot has push); credential-resolution is
# operator ergonomics and matches the launch_fork.sh contract so an
# operator can keep one set of HAL_* entries in the shared env file.
#
# Safety guarantees
# -----------------
# - Validates via a single read-only `GET /repos/<owner>/<repo>`:
# * repo must exist
# * repo MUST NOT be a fork (the inverse of launch_fork.sh's check)
# * calling user MUST have push permission
# - All resolved values stay in LOCAL variables until validation passes.
# On any failure, the parent shell's environment is left untouched —
# no FORGEJO_*, no GIT_*, no GITEA_TOKEN leakage.
# - Banner prints a hard-to-miss PROD-MODE warning before any controller
# action so the operator confirms they meant to launch against the
# canonical repo.
#
# Exit / return codes
# -------------------
# 0 — env exported, prod target validated
# 1 — validation failed; nothing exported
# 2 — argument error / missing prerequisite
# Detect sourced vs executed.
__lp_sourced=0
if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then
__lp_sourced=1
fi
__lp_main() {
local script_path="${BASH_SOURCE[0]:-$0}"
local repo_root
repo_root="$(cd "$(dirname "$script_path")/.." && pwd)"
# ─── Defaults (overridable via PROD_* env vars) ───────────────────
local prod_owner="${PROD_OWNER:-cleveragents}"
local prod_repo="${PROD_REPO:-cleveragents-core}"
local prod_url="${PROD_URL:-https://git.cleverthis.com}"
local prod_api_base="${PROD_API_BASE:-${prod_url%/}/api/v1}"
local prod_default_branch="${PROD_DEFAULT_BRANCH:-master}"
# ─── Step 0 — pick a python3 ──────────────────────────────────────
local py
if [[ -x "$repo_root/.venv/bin/python3" ]]; then
py="$repo_root/.venv/bin/python3"
else
py="$(command -v python3 || true)"
fi
if [[ -z "$py" ]]; then
echo "ERROR: python3 not found (needed for prod validation + JSON parsing)" >&2
return 2
fi
# ─── Step 1 — small .env reader (.env.prod overlay first, then shared) ─
# Looks at .devcontainer/.env.prod first (the conventional placement),
# then repo-root .env.prod as a fallback, then the shared env files.
__lp_resolve_from_env() {
local key="$1"
local val=""
for f in \
"$repo_root/.devcontainer/.env.prod" \
"$repo_root/.env.prod" \
"$repo_root/.devcontainer/.env" \
"$repo_root/.env"; do
[[ -f "$f" ]] || continue
val="$(awk -v k="$key" -F= '
$0 ~ "^[[:space:]]*"k"[[:space:]]*=" {
sub("^[[:space:]]*"k"[[:space:]]*=[[:space:]]*", "")
if (substr($0,1,1) == "\"" || substr($0,1,1) == "'\''") {
q = substr($0,1,1)
sub("^"q, "")
sub(q".*$", "")
} else {
sub(/[[:space:]#].*$/, "")
}
print
exit
}' "$f" 2>/dev/null || true)"
if [[ -n "$val" ]]; then
printf '%s' "$val"
return 0
fi
done
return 1
}
# Resolve <CANONICAL_NAME> with optional fallback to HAL_* aliases —
# parity with launch_fork.sh so an operator can keep the same
# alias-style entries in .devcontainer/.env and have them resolve in
# both modes. The PROD safety primitive remains the affirmative repo
# validation below; alias support is purely an ergonomics choice.
__lp_resolve() {
local canonical="$1"; shift
local existing="${!canonical:-}"
if [[ -n "$existing" ]]; then
printf '%s' "$existing"
return 0
fi
local v
if v="$(__lp_resolve_from_env "$canonical")"; then
printf '%s' "$v"
return 0
fi
local alias
for alias in "$@"; do
if v="$(__lp_resolve_from_env "$alias")"; then
printf '%s' "$v"
return 0
fi
done
return 1
}
# Resolve a Forgejo login from a PAT via /api/v1/user.
__lp_whoami() {
local token="$1"
[[ -n "$token" ]] || return 1
curl -sf -H "Authorization: token $token" "$prod_api_base/user" 2>/dev/null \
| "$py" -c 'import json,sys
try:
print(json.load(sys.stdin).get("login") or "")
except Exception:
pass' 2>/dev/null || true
}
# ─── Step 2 — resolve every value into local vars ────────────────
local resolved_pat resolved_pwd
resolved_pat="$(__lp_resolve FORGEJO_PAT HAL_9000_API_KEY)" || resolved_pat=""
resolved_pwd="$(__lp_resolve FORGEJO_PASSWORD HAL_9000_FORGEJO_PASSWORD)" || resolved_pwd=""
# GITEA_TOKEN is synthesized from FORGEJO_PAT (HAL9000) by default —
# production-fidelity identity for the deterministic Python tools.
local resolved_gitea="${GITEA_TOKEN:-$resolved_pat}"
if [[ -z "$resolved_gitea" ]]; then
resolved_gitea="$(__lp_resolve GITEA_TOKEN)" || resolved_gitea=""
fi
local resolved_rev_pat resolved_rev_pwd
resolved_rev_pat="$(__lp_resolve FORGEJO_REVIEWER_PAT HAL_9001_API_KEY)" || resolved_rev_pat=""
resolved_rev_pwd="$(__lp_resolve FORGEJO_REVIEWER_PASSWORD HAL_9001_FORGEJO_PASSWORD)" || resolved_rev_pwd=""
local resolved_git_name resolved_git_email
resolved_git_name="$(__lp_resolve GIT_USER_NAME)" || resolved_git_name="CleverThis"
resolved_git_email="$(__lp_resolve GIT_USER_EMAIL)" || resolved_git_email="hal9000@cleverthis.com"
local resolved_workers
resolved_workers="$(__lp_resolve CA_MAX_PARALLEL_WORKERS)" || resolved_workers=""
local resolved_user resolved_rev_user
resolved_user="$(__lp_resolve FORGEJO_USERNAME)" || resolved_user=""
if [[ -z "$resolved_user" && -n "$resolved_pat" ]]; then
resolved_user="$(__lp_whoami "$resolved_pat")"
fi
resolved_rev_user="$(__lp_resolve FORGEJO_REVIEWER_USERNAME)" || resolved_rev_user=""
if [[ -z "$resolved_rev_user" && -n "$resolved_rev_pat" ]]; then
resolved_rev_user="$(__lp_whoami "$resolved_rev_pat")"
fi
# ─── Step 3 — required-var validation ────────────────────────────
local -a missing=()
[[ -n "$resolved_pat" ]] || missing+=("FORGEJO_PAT")
[[ -n "$resolved_pwd" ]] || missing+=("FORGEJO_PASSWORD")
[[ -n "$resolved_user" ]] || missing+=("FORGEJO_USERNAME (no whoami match — PAT invalid?)")
[[ -n "$resolved_rev_pat" ]] || missing+=("FORGEJO_REVIEWER_PAT")
[[ -n "$resolved_rev_pwd" ]] || missing+=("FORGEJO_REVIEWER_PASSWORD")
[[ -n "$resolved_rev_user" ]] || missing+=("FORGEJO_REVIEWER_USERNAME (no whoami match — reviewer PAT invalid?)")
[[ -n "$resolved_gitea" ]] || missing+=("GITEA_TOKEN (or FORGEJO_PAT to synthesize from)")
if (( ${#missing[@]} > 0 )); then
echo "ERROR: PROD launch missing required env vars (set canonical names in .env.prod or .devcontainer/.env):" >&2
local m
for m in "${missing[@]}"; do
echo " - $m" >&2
done
echo "Refusing to launch prod-mode pipeline; nothing exported." >&2
return 1
fi
# ─── Step 4 — affirmative target validation ──────────────────────
# Target MUST exist, MUST NOT be a fork, caller MUST have push.
# This is the inverse of launch_fork.sh's primitive — explicitly
# confirming we're pointed at the canonical repo before any code
# touches it.
local validation_json
if ! validation_json="$(
GITEA_TOKEN="$resolved_gitea" \
PROD_OWNER="$prod_owner" \
PROD_REPO="$prod_repo" \
PROD_API_BASE="$prod_api_base" \
"$py" - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
owner = os.environ["PROD_OWNER"]
repo = os.environ["PROD_REPO"]
base = os.environ["PROD_API_BASE"].rstrip("/")
url = f"{base}/repos/{owner}/{repo}"
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read())
except urllib.error.HTTPError as e:
msg = e.read().decode("utf-8", "replace")[:200]
print(f"ERROR: GET {url} returned HTTP {e.code}: {msg}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: GET {url} failed: {e!r}", file=sys.stderr)
sys.exit(1)
problems: list[str] = []
if body.get("fork"):
parent = (body.get("parent") or {}).get("full_name")
problems.append(
f"target {owner}/{repo} IS a fork (parent={parent!r}); "
"refusing to launch PROD-mode pipeline against a fork. Use "
"tools/launch_fork.sh + omit --prod for fork targets."
)
if body.get("archived"):
problems.append(
f"target {owner}/{repo} is archived (read-only); "
"controller cannot push branches or merge PRs."
)
perms = body.get("permissions") or {}
if not perms.get("push"):
problems.append(
f"calling user lacks push permission on {owner}/{repo} "
f"(permissions={perms!r}); controller cannot push branches or merge PRs."
)
if problems:
for p in problems:
print(f"ERROR: {p}", file=sys.stderr)
sys.exit(1)
print(json.dumps({
"owner": owner,
"repo": repo,
"full_name": body.get("full_name"),
"default_branch": body.get("default_branch"),
"private": body.get("private"),
"archived": body.get("archived"),
}))
PY
)"; then
echo "ERROR: prod target validation failed; nothing exported." >&2
return 1
fi
# ─── Step 5 — commit resolved values to the parent shell ─────────
# Driver-side
export GITEA_TOKEN="$resolved_gitea"
export FORGEJO_OWNER="$prod_owner"
export FORGEJO_REPO="$prod_repo"
export FORGEJO_URL="$prod_url"
export FORGEJO_API_BASE="$prod_api_base"
export FORGEJO_DEFAULT_BRANCH="$prod_default_branch"
export FORGEJO_ORG="${FORGEJO_ORG:-cleveragents}"
# Convenience: the controller launcher reads CONTROLLER_OWNER / REPO
# with a fallback to FORK_OWNER / drew. Set these so it picks up the
# right values without further plumbing.
export CONTROLLER_OWNER="$prod_owner"
export CONTROLLER_REPO="$prod_repo"
# Auto-agents env contract (8 required + 1 optional)
export GIT_USER_NAME="$resolved_git_name"
export GIT_USER_EMAIL="$resolved_git_email"
export FORGEJO_PAT="$resolved_pat"
export FORGEJO_USERNAME="$resolved_user"
export FORGEJO_PASSWORD="$resolved_pwd"
export FORGEJO_REVIEWER_PAT="$resolved_rev_pat"
export FORGEJO_REVIEWER_USERNAME="$resolved_rev_user"
export FORGEJO_REVIEWER_PASSWORD="$resolved_rev_pwd"
if [[ -n "$resolved_workers" ]]; then
export CA_MAX_PARALLEL_WORKERS="$resolved_workers"
fi
# Production-leaning defaults. Each is overridable — pre-export to
# change. (Fork mode defaults to MERGE_DRIVER_LOG_LEVEL=DEBUG for
# operator visibility during trials; prod defaults to INFO to keep
# the loop-merge log quiet on idle cycles.)
export IMPLEMENTER_ESTIMATOR_ENABLED="${IMPLEMENTER_ESTIMATOR_ENABLED:-1}"
export MERGE_DRIVER_LOG_LEVEL="${MERGE_DRIVER_LOG_LEVEL:-INFO}"
export REVIEW_DISPATCHER_USE_PYTHON_FILTERS="${REVIEW_DISPATCHER_USE_PYTHON_FILTERS:-1}"
# Mode marker so downstream code can branch (e.g. telemetry banner).
export CLEVERAGENTS_LAUNCH_MODE=prod
# ─── Step 6 — banner ────────────────────────────────────────────
local archived private
archived="$(printf '%s' "$validation_json" | "$py" -c '
import json,sys
print(json.load(sys.stdin).get("archived"))' 2>/dev/null)"
private="$(printf '%s' "$validation_json" | "$py" -c '
import json,sys
print(json.load(sys.stdin).get("private"))' 2>/dev/null)"
# Identity check on the synthesized GITEA_TOKEN.
local gitea_login
gitea_login="$(__lp_whoami "$resolved_gitea")"
local gitea_note=""
if [[ "$resolved_gitea" == "$resolved_pat" ]]; then
gitea_note="(== FORGEJO_PAT)"
else
gitea_note="(operator-overridden)"
fi
echo "=================================================================="
echo " ⚠ PROD-MODE env locked for the auto-agents controller"
echo " ----------------------------------------------------------------"
echo " THIS WILL WRITE TO THE CANONICAL REPO. The controller will"
echo " push branches, label PRs, post reviews, and MERGE on:"
echo " $FORGEJO_OWNER/$FORGEJO_REPO (private=$private, archived=$archived)"
echo " If this is not what you intended, ^C now and re-launch without"
echo " --prod (or unset MODE=prod) to run against your fork instead."
echo " ----------------------------------------------------------------"
echo " Repo target"
echo " FORGEJO_OWNER = $FORGEJO_OWNER"
echo " FORGEJO_REPO = $FORGEJO_REPO"
echo " FORGEJO_URL = $FORGEJO_URL"
echo " FORGEJO_API_BASE = $FORGEJO_API_BASE"
echo " FORGEJO_DEFAULT_BRANCH = $FORGEJO_DEFAULT_BRANCH"
echo " FORGEJO_ORG = $FORGEJO_ORG"
echo
echo " Identities"
echo " FORGEJO_PAT -> ${FORGEJO_USERNAME} (controller + workers, ${#FORGEJO_PAT} chars)"
echo " FORGEJO_PASSWORD = set (${#FORGEJO_PASSWORD} chars)"
echo " FORGEJO_REVIEWER_PAT -> ${FORGEJO_REVIEWER_USERNAME} (reviewer, ${#FORGEJO_REVIEWER_PAT} chars)"
echo " FORGEJO_REVIEWER_PASSWORD = set (${#FORGEJO_REVIEWER_PASSWORD} chars)"
echo " GITEA_TOKEN -> ${gitea_login:-<unknown>} $gitea_note"
echo
echo " Git author for HAL9000 commits"
echo " GIT_USER_NAME = $GIT_USER_NAME"
echo " GIT_USER_EMAIL = $GIT_USER_EMAIL"
if [[ -n "${CA_MAX_PARALLEL_WORKERS:-}" ]]; then
echo
echo " Parallelism"
echo " CA_MAX_PARALLEL_WORKERS = $CA_MAX_PARALLEL_WORKERS"
fi
echo
echo " Production defaults applied (override via env or .env.prod)"
echo " MERGE_DRIVER_LOG_LEVEL = $MERGE_DRIVER_LOG_LEVEL"
echo " IMPLEMENTER_ESTIMATOR_ENABLED = $IMPLEMENTER_ESTIMATOR_ENABLED"
echo " REVIEW_DISPATCHER_USE_PYTHON_FILTERS = $REVIEW_DISPATCHER_USE_PYTHON_FILTERS"
echo "=================================================================="
return 0
}
__lp_main "$@"
__lp_rc=$?
if [[ $__lp_sourced -eq 0 && $__lp_rc -eq 0 ]]; then
echo
echo "WARNING: this script was EXECUTED, not sourced. The exports above"
echo "will not persist into your shell. Re-run as:"
echo
echo " source tools/launch_prod.sh"
echo
fi
# Bash treats `return` outside a function as an error when EXECUTED, so
# branch on sourced-vs-executed here. When sourced we MUST `return`
# (an `exit` would terminate the parent shell). When executed we MUST
# `exit`.
if [[ $__lp_sourced -eq 1 ]]; then
return $__lp_rc
else
exit $__lp_rc
fi