2026-05-11 02:15:47 +02:00
|
|
|
"""Verify postgres agentrole + team enums match foundation/identity.
|
|
|
|
|
|
|
|
|
|
Run: `uv run python scripts/verify_postgres_enums.py`
|
|
|
|
|
|
2026-06-29 05:38:21 +02:00
|
|
|
Exit codes:
|
|
|
|
|
0 — match, OR skip (postgres unreachable / DB not migrated: both enum
|
|
|
|
|
types absent). A skip is not a failure: the gate only fails on real
|
|
|
|
|
drift against a schema that actually has the enum types.
|
|
|
|
|
1 — drift (the enum types exist but their labels differ from foundation).
|
|
|
|
|
An Alembic migration must be written to align postgres with foundation.
|
2026-05-11 02:15:47 +02:00
|
|
|
|
2026-06-29 05:38:21 +02:00
|
|
|
Reads DB connection from roboco.config. Skip semantics live IN the script
|
|
|
|
|
(exit 0) so the Makefile gate can't mask a real drift exit as "skipped".
|
2026-05-11 02:15:47 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import asyncpg
|
|
|
|
|
from roboco.config import settings
|
|
|
|
|
from roboco.foundation import identity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_enum_values(conn: asyncpg.Connection, enum_name: str) -> set[str]:
|
|
|
|
|
rows = await conn.fetch(
|
|
|
|
|
"""
|
|
|
|
|
SELECT e.enumlabel
|
|
|
|
|
FROM pg_enum e
|
|
|
|
|
JOIN pg_type t ON e.enumtypid = t.oid
|
|
|
|
|
WHERE t.typname = $1
|
|
|
|
|
ORDER BY e.enumsortorder
|
|
|
|
|
""",
|
|
|
|
|
enum_name,
|
|
|
|
|
)
|
|
|
|
|
return {r["enumlabel"] for r in rows}
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 05:38:21 +02:00
|
|
|
async def type_exists(conn: asyncpg.Connection, enum_name: str) -> bool:
|
|
|
|
|
"""True iff a postgres enum type named ``enum_name`` exists."""
|
|
|
|
|
return bool(
|
|
|
|
|
await conn.fetchval(
|
|
|
|
|
"SELECT EXISTS (SELECT 1 FROM pg_type WHERE typname = $1)", enum_name
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def should_skip_for_unmigrated(agentrole_exists: bool, team_exists: bool) -> bool:
|
|
|
|
|
"""Skip only when BOTH enum types are absent (DB schema not migrated).
|
|
|
|
|
|
|
|
|
|
A partial schema (one type present, the other absent) is itself drift —
|
|
|
|
|
don't skip; let ``enum_drift`` report it. An empty/unmigrated DB (both
|
|
|
|
|
absent) has no migrated target to check, so skipping is correct.
|
|
|
|
|
"""
|
|
|
|
|
return not agentrole_exists and not team_exists
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def enum_drift(
|
|
|
|
|
db_roles: set[str],
|
|
|
|
|
db_teams: set[str],
|
|
|
|
|
foundation_roles: set[str],
|
|
|
|
|
foundation_teams: set[str],
|
|
|
|
|
) -> tuple[bool, list[str]]:
|
|
|
|
|
"""Compare DB enum labels to foundation; return (has_drift, messages)."""
|
|
|
|
|
messages: list[str] = []
|
|
|
|
|
missing_roles = foundation_roles - db_roles
|
|
|
|
|
extra_roles = db_roles - foundation_roles
|
|
|
|
|
missing_teams = foundation_teams - db_teams
|
|
|
|
|
extra_teams = db_teams - foundation_teams
|
|
|
|
|
if missing_roles:
|
|
|
|
|
messages.append(f"DRIFT: postgres agentrole missing: {sorted(missing_roles)}")
|
|
|
|
|
if extra_roles:
|
|
|
|
|
messages.append(f"DRIFT: postgres agentrole has extra: {sorted(extra_roles)}")
|
|
|
|
|
if missing_teams:
|
|
|
|
|
messages.append(f"DRIFT: postgres team missing: {sorted(missing_teams)}")
|
|
|
|
|
if extra_teams:
|
|
|
|
|
messages.append(f"DRIFT: postgres team has extra: {sorted(extra_teams)}")
|
|
|
|
|
return (bool(messages), messages)
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 02:15:47 +02:00
|
|
|
async def main() -> int:
|
|
|
|
|
try:
|
|
|
|
|
conn = await asyncpg.connect(
|
|
|
|
|
host=settings.database_host,
|
|
|
|
|
port=settings.database_port,
|
|
|
|
|
user=settings.database_user,
|
|
|
|
|
password=settings.database_password,
|
|
|
|
|
database=settings.database_name,
|
|
|
|
|
)
|
|
|
|
|
except (OSError, asyncpg.PostgresError) as exc:
|
|
|
|
|
print(f"postgres unreachable: {exc}")
|
2026-06-29 05:38:21 +02:00
|
|
|
print(" (skipped — postgres unreachable)")
|
|
|
|
|
return 0
|
2026-05-11 02:15:47 +02:00
|
|
|
try:
|
2026-06-29 05:38:21 +02:00
|
|
|
agentrole_exists = await type_exists(conn, "agentrole")
|
|
|
|
|
team_exists = await type_exists(conn, "team")
|
|
|
|
|
if should_skip_for_unmigrated(agentrole_exists, team_exists):
|
|
|
|
|
print(
|
|
|
|
|
" (skipped — DB schema not migrated: agentrole/team enum types absent)"
|
|
|
|
|
)
|
|
|
|
|
return 0
|
2026-05-11 02:15:47 +02:00
|
|
|
agentrole_db = await fetch_enum_values(conn, "agentrole")
|
|
|
|
|
team_db = await fetch_enum_values(conn, "team")
|
|
|
|
|
finally:
|
|
|
|
|
await conn.close()
|
|
|
|
|
|
|
|
|
|
foundation_roles = {r.value for r in identity.Role}
|
|
|
|
|
foundation_teams = {t.value for t in identity.Team}
|
2026-06-29 05:38:21 +02:00
|
|
|
has_drift, messages = enum_drift(
|
|
|
|
|
agentrole_db, team_db, foundation_roles, foundation_teams
|
|
|
|
|
)
|
|
|
|
|
if has_drift:
|
|
|
|
|
for line in messages:
|
|
|
|
|
print(line)
|
2026-05-11 02:15:47 +02:00
|
|
|
print("Write an Alembic migration to align postgres with foundation.")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
f"OK: agentrole has {len(agentrole_db)} values; team has {len(team_db)} values."
|
|
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(asyncio.run(main()))
|