Files
turnstone/tests/test_schema_parity.py
T
Patrick Buckley dbf389783e refactor(personas): file-backed built-in prompts, explicit source column
Built-in persona base prompts move from inline DB text / base.md into
prompts/personas/<slug>.md — code-owned, PR-reviewable, drift-proof.
base.md / base_coordinator.md become personas/engineer.md / orchestrator.md.

Prompt source is now explicit in storage instead of inferred in app logic:
a new base_prompt_file column plus CHECK (base_prompt IS NOT NULL OR
base_prompt_file IS NOT NULL) — two nullable columns, never both empty.
Resolution is a coalesce (base_prompt else load(base_prompt_file)), frozen
into the workstream stamp at creation. base_prompt_file marks a persona as
built-in (code-only, un-archivable); an operator override on a built-in is
allowed and wins over the file. "Inherit the kind default" is a
workstream-creation act (is_default), not a persona-row state.

Migration 063:
- seeds reference their file (base_prompt NULL); no runtime file reads —
  the backfill's frozen prompt text is inlined as a point-in-time snapshot
  so migration history stays self-contained and reproducible.
- every existing workstream is stamped by kind (creative -> writer, else
  the kind default), set-based (INSERT..SELECT via temp tables) with the
  persona column added after the bulk writes to shorten its lock window.

Storage guards (both backends): operators must supply base_prompt;
built-ins can't be archived or have base_prompt_file set via the API;
clearing an operator persona's only source is rejected.

Follow-ups reviewed alongside (#756): soft-set visibility docstring scoped
to per-process; _apply_persona_snapshot / _current_persona_snapshot own the
stamp round-trip; spawn approval-header args (skill/name/target_node)
flattened+capped like persona; server-side tool injection generalized to
replace-only (client-def gated, incl. the xAI include forwarding). Seed
copy revised (researcher soft; de-costumed prose; engineer de-biased).
New test_schema_parity asserts create_all matches the alembic head.

Closes #683 groundwork; ruff + strict mypy clean, full suite green.
2026-07-03 00:29:26 -07:00

88 lines
3.6 KiB
Python

"""Schema parity: `metadata.create_all` must match `alembic upgrade head`.
The codebase defines its schema twice — `_schema.py` (the SQLAlchemy metadata
that `create_all` builds, used for fast ephemeral test DBs and
``SQLiteBackend(create_tables=True)``) and the Alembic migration chain (which
builds production DBs incrementally). They are kept in sync BY HAND.
Nothing else enforces that they agree, so a column added to a migration but not
to `_schema.py` (or the reverse) would silently give `create_all`-based tests a
different schema than production — and most tests use `create_all`, so a
migration bug could pass CI unnoticed. This test is that enforcement: it fails
the moment the two paths drift on a table, column, or named constraint.
(It does NOT check seed DATA: `create_all` builds structure only, so migration
seeds — e.g. the built-in personas — exist only on migrated DBs. Tests that
need seed rows must run migrations or seed explicitly; that gap is by design.)
"""
from __future__ import annotations
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
_MIGRATIONS = str(Path(__file__).resolve().parent.parent / "turnstone/core/storage/migrations")
def _inspect_migrated(db_path: Path) -> sa.Inspector:
cfg = Config()
cfg.set_main_option("script_location", _MIGRATIONS)
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
command.upgrade(cfg, "head")
return sa.inspect(sa.create_engine(f"sqlite:///{db_path}"))
def _inspect_create_all(db_path: Path) -> sa.Inspector:
from turnstone.core.storage._schema import metadata
engine = sa.create_engine(f"sqlite:///{db_path}")
metadata.create_all(engine)
return sa.inspect(engine)
def test_create_all_matches_migrations(tmp_path: Path) -> None:
mig = _inspect_migrated(tmp_path / "migrated.db")
meta = _inspect_create_all(tmp_path / "create_all.db")
mig_tables = set(mig.get_table_names()) - {"alembic_version"}
meta_tables = set(meta.get_table_names())
assert mig_tables == meta_tables, (
f"table drift — only in migrations: {sorted(mig_tables - meta_tables)}; "
f"only in create_all: {sorted(meta_tables - mig_tables)}"
)
col_drift: dict[str, dict[str, list[str]]] = {}
check_drift: dict[str, dict[str, list[str]]] = {}
for t in sorted(mig_tables):
mc = {c["name"] for c in mig.get_columns(t)}
ec = {c["name"] for c in meta.get_columns(t)}
if mc != ec:
col_drift[t] = {
"only_migrations": sorted(mc - ec),
"only_create_all": sorted(ec - mc),
}
# Named CHECK constraints only — unnamed ones reflect as backend noise.
mck = {c["name"] for c in mig.get_check_constraints(t) if c.get("name")}
eck = {c["name"] for c in meta.get_check_constraints(t) if c.get("name")}
if mck != eck:
check_drift[t] = {
"only_migrations": sorted(mck - eck),
"only_create_all": sorted(eck - mck),
}
assert not col_drift, f"column drift: {col_drift}"
assert not check_drift, f"check-constraint drift: {check_drift}"
def test_personas_prompt_source_check_present_on_both_paths(tmp_path: Path) -> None:
# Guards the personas feature specifically: the base_prompt/base_prompt_file
# source CHECK must exist on BOTH build paths, not just the one under test.
mig = _inspect_migrated(tmp_path / "m.db")
meta = _inspect_create_all(tmp_path / "c.db")
for insp in (mig, meta):
names = {c.get("name") for c in insp.get_check_constraints("personas")}
assert "ck_personas_prompt_source" in names