Files
cleveragents-core/tools/controller/deploy/validate_db_mode.py
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

249 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Verify a SQLite db belongs to the launch mode (fork vs prod).
Called from ``tools/run-controller-state-machine-pipeline.sh`` once per
db (the controller db and the telemetry cache db) right after the
env is loaded and BEFORE the controller / OpenCode / telemetry start.
What it does
------------
- File missing → OK (will be created fresh by the writer).
- File exists with a ``_mode_marker`` table:
* marker mode == requested mode → OK
* marker mode != requested mode → **move file aside** to
``<file>.<prior-mode>.bak.<UTC-timestamp>`` and return. The writer
will recreate the file fresh on first use.
- File exists without a ``_mode_marker``:
* has no user tables (empty schema) → stamp and return.
* has data (pre-marker era — e.g. the legacy ``tools/.cache/forgejo.sqlite``
from the dispatcher era) →
- default behavior: move aside as ``<file>.unmarked.bak.<ts>``.
Safest assumption when we can't prove it belongs.
- with ``--adopt``: stamp the existing data as the current mode
(operator vouches that it belongs). Useful on first launch
after this check is rolled out, to grandfather in already-good
fork or prod data without losing history.
The script never deletes anything. Backups stay in place until the
operator removes them.
CLI
---
Pass either an absolute file path or a SQLAlchemy URL — non-SQLite URLs
(e.g. postgresql://...) are reported and skipped:
validate_db_mode.py --mode prod --db /var/lib/cleveragents/db/controller.db
validate_db_mode.py --mode prod --sqlalchemy-url "$CLEVERAGENTS_DB_URL"
validate_db_mode.py --mode prod --db tools/.cache/forgejo.sqlite --label cache
The script exits 0 in every benign db-state case (missing, matching,
freshly stamped, backed-up-and-cleared). Exit 1 is reserved for argparse
errors (caller passed something wrong) — a real db inspection never
returns 1, so the launcher's ``|| die`` only fires when the script
itself was invoked incorrectly.
"""
from __future__ import annotations
import argparse
import datetime as dt
import sqlite3
import sys
from pathlib import Path
from urllib.parse import urlparse
# Schema kept here so the launcher and any future caller agree.
_MARKER_DDL = (
"CREATE TABLE IF NOT EXISTS _mode_marker ("
" mode TEXT PRIMARY KEY,"
" stamped_at TEXT NOT NULL"
")"
)
def _resolve_path(db_arg: str | None, url_arg: str | None) -> Path | None:
"""Return the SQLite file path, or None when the URL points at a
non-SQLite backend (postgres etc. — nothing for us to do)."""
if db_arg:
return Path(db_arg).expanduser()
if not url_arg:
return None
parsed = urlparse(url_arg)
if not parsed.scheme.startswith("sqlite"):
return None
# SQLAlchemy sqlite URLs:
# sqlite:///relative/path → urlparse.path = '/relative/path' (the first
# slash is the URL-path leading slash; the rest
# is the "relative path" — confusingly absolute-
# looking from urlparse)
# sqlite:////abs/path → urlparse.path = '//abs/path' (double-leading
# slash flags absolute on POSIX)
raw = parsed.path
if raw.startswith("//"):
return Path(raw[1:]).expanduser() # absolute → /abs/path
if raw.startswith("/"):
return Path(raw[1:]).expanduser() # SQLAlchemy "relative" — strip leading /
return Path(raw).expanduser()
def _log(label: str, level: str, msg: str) -> None:
print(f" [{label}] {level}: {msg}", file=sys.stderr)
def _backup(db_path: Path, prior_mode: str) -> Path:
ts = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup = db_path.with_name(db_path.name + f".{prior_mode}.bak.{ts}")
db_path.rename(backup)
return backup
def _stamp(db_path: Path, mode: str) -> None:
"""Create the marker table (if absent) and write the current mode."""
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
conn.execute(_MARKER_DDL)
conn.execute(
"INSERT OR REPLACE INTO _mode_marker (mode, stamped_at) VALUES (?, ?)",
(mode, dt.datetime.now(dt.timezone.utc).isoformat()),
)
conn.commit()
finally:
conn.close()
def _user_tables(conn: sqlite3.Connection) -> list[str]:
"""Return user-table names — exclude SQLite internals and our own
marker so we can detect a "schema present but no data" situation."""
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' "
" AND name NOT LIKE 'sqlite_%' "
" AND name NOT LIKE '\\_%' ESCAPE '\\'"
).fetchall()
return [r[0] for r in rows]
def _has_any_data(conn: sqlite3.Connection, tables: list[str]) -> bool:
for t in tables:
try:
cnt = conn.execute(f'SELECT COUNT(*) FROM "{t}"').fetchone()[0]
if cnt:
return True
except sqlite3.DatabaseError:
# Treat unreadable as data-bearing to be safe.
return True
return False
def validate(db_path: Path, mode: str, label: str, adopt: bool = False) -> int:
if not db_path.exists():
_log(label, "ok", f"{db_path} does not exist; will be created fresh in {mode} mode")
# We can't stamp a non-existent file (the writer needs to create
# it first with its own schema). Stamping happens on first
# subsequent launch once data shows up.
return 0
try:
conn = sqlite3.connect(str(db_path))
except sqlite3.DatabaseError as exc:
_log(label, "warn", f"{db_path} not openable as sqlite ({exc!r}); leaving alone")
return 0
try:
marker_present = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='_mode_marker'"
).fetchone() is not None
if marker_present:
row = conn.execute(
"SELECT mode FROM _mode_marker LIMIT 1"
).fetchone()
existing = row[0] if row else None
if existing == mode:
_log(label, "ok", f"{db_path} mode marker confirms {existing!r}")
return 0
conn.close()
backup = _backup(db_path, existing or "unknown")
_log(label, "WIPED",
f"{db_path} stamped as {existing!r} but launching {mode!r}; "
f"moved aside → {backup.name}")
return 0
# No marker. Decide based on whether there's any data.
tables = _user_tables(conn)
if not tables or not _has_any_data(conn, tables):
conn.close()
_stamp(db_path, mode)
_log(label, "ok",
f"{db_path} had no marker and no data; stamped as {mode!r}")
return 0
# Has data without a marker — almost certainly pre-marker era
# state from before this check existed.
conn.close()
if adopt:
# Operator vouches this data belongs to the current mode.
_stamp(db_path, mode)
_log(label, "ok",
f"{db_path} had data but no marker — ADOPTED as {mode!r} "
"(operator vouched via --adopt)")
return 0
backup = _backup(db_path, "unmarked")
_log(label, "WIPED",
f"{db_path} had data but no mode marker (pre-marker state); "
f"moved aside → {backup.name} "
f"(re-run with --adopt to grandfather it in instead)")
return 0
finally:
try:
conn.close()
except sqlite3.Error:
pass
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Verify a SQLite db belongs to the launch mode (fork vs prod).",
)
parser.add_argument(
"--mode", choices=("fork", "prod"), required=True,
help="Launch mode the controller is starting in.",
)
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument("--db", help="SQLite file path (may not exist yet).")
src.add_argument(
"--sqlalchemy-url",
help="SQLAlchemy URL — only sqlite:// URLs are validated; "
"anything else is reported and skipped.",
)
parser.add_argument(
"--label", default="db",
help="Short label used in log output (e.g. 'controller', 'cache').",
)
parser.add_argument(
"--adopt", action="store_true",
help="If the db has data without a mode marker, STAMP it as the "
"current mode rather than backing it up. Use this on first "
"rollout to grandfather in already-good data without losing "
"history. NOT for normal launches — only when the operator "
"knows the existing data belongs to the requested mode.",
)
args = parser.parse_args(argv)
db_path = _resolve_path(args.db, args.sqlalchemy_url)
if db_path is None:
_log(args.label, "skip",
f"non-sqlite backend ({args.sqlalchemy_url!r}) — mode marker "
"check not implemented for this backend; relying on the URL "
"itself to point at the right db")
return 0
return validate(db_path, args.mode, args.label, adopt=args.adopt)
if __name__ == "__main__":
raise SystemExit(main())