Three features sharing one idea: a model here started from nothing every
conversation and had no notion that anything else existed.
THE ROSTER. `chat.roster_block` builds one line per model this *person* can
reach -- through `permissions.models_visible_to`, never the table -- and
`{{model_roster}}` carries it, gated on the `friend` family for the reason the
memories block is gated on `memory`: a list of peers a model cannot talk to is
context spent on nothing, and one checkbox is then the whole switch. New
`Model.notes` column, a column and not a `capabilities_json` key for the reason
`context_length` and `reasoning_efforts` both carry.
ASKING A FRIEND. A second entry point in `services/subagent.py` rather than a
second module, so one place still owns the bounds and the lifecycle. `_create_
child` takes the friend's (model_id, connection_id) *pair*, because Model is
unique on both and an id alone does not say which endpoint. Three things differ
from a helper: the effort is the friend's own default and never the parent's (the
1.3.0 bug by another door -- the vocabularies differ and a level a model does not
take raises inside its chat template), the chat is ordinary even when the asker's
is an agent chat, and `scope_json["role"]` marks it so `core.friend` speaks
instead of `core.subagent`. `friend` joins the unattended withdrawal set: a
friend that could ask a friend is the same unbounded fan-out in politer clothes.
Budget, concurrency and quota are shared with helpers, so one reply cannot spend
the allowance twice.
PERSONALITY. One table, two roles, `owner_id IS NULL` the discriminator: the
model's own persona, and its read of one person. Keyed on the model's *text* id
with no foreign key, because "Test & refresh" deletes a model the endpoint has
stopped listing and a personality must not be collateral. `PersonaRevision`
copies SkillRevision, and so does the argument: the safety story for a model
rewriting itself is a record and a way back, not a gate. The reflection is shown
to the person it is about, in their own settings, which is the whole of why
keeping one is acceptable. `persona` is withdrawn from any unattended chat --
a helper's task, a friend's question and a schedule's instruction are all words
nobody watched being written.
Two bugs found while reading for this, both silent:
`review_model_id` stored a `Model` primary key, so a refresh taken while an
endpoint was not listing that model unset the administrator's choice -- and
`_reviewer` then fell back to the chat's own model, so pictures were judged by
a model nobody chose. Now the text id, with the primary key still accepted.
`_messages_after` used a bare `>` on `created_at`, so a row sharing the edited
turn's microsecond survived a rewind -- and `_send` writes a user turn and its
placeholder back to back, which is exactly that tie. Deliberately NOT
`thread_tail`'s `(created_at, id)` tiebreak: ids are random UUIDs, so that
settles a tie by coin toss. A tie now reads as "later", which is the safe
direction for an operation whose purpose is to discard what follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""The upgrade path, which every existing install takes to reach 1.0.0.
|
|
|
|
There is no Alembic here by design -- hard rule 4, additive-only, with
|
|
`db/migrations.py:sync_schema` deriving the change from the models by diffing
|
|
them against the live database. That makes the differ the *only* thing standing
|
|
between an 0.8.x deployment and a broken one, and until this file it had no test
|
|
that exercised it as a migration at all.
|
|
|
|
`tests/conftest.py` runs `create_all` and *then* `sync_schema`, so the schema it
|
|
is handed is always already current: the differ finds nothing missing, does
|
|
nothing, and reports success. Every run of the suite proved that a no-op is a
|
|
no-op. The two unit tests that did exist covered `_add_column_sql`'s DDL string
|
|
and never touched a database.
|
|
|
|
So these build an **old-shaped database with rows in it** and upgrade it for
|
|
real. The shape is not invented: `OLD_TABLES` and `OLD_COLUMNS` are what
|
|
actually arrived between `0.8.1` and this release, taken from
|
|
`git diff 00ce04a..HEAD -- src/lembas/db/models/`. `test_the_recorded_shape_is_still_real`
|
|
is what stops that list rotting into a test that upgrades nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import inspect, text
|
|
|
|
from lembas.db.base import Base
|
|
from lembas.db.migrations import ensure_fts, sync_schema
|
|
from lembas.db.session import get_engine
|
|
|
|
# Tables that did not exist at 0.8.1. `sync_schema` has to create them.
|
|
OLD_TABLES = ("chunks", "push_subscriptions", "usage", "personas", "persona_revisions")
|
|
|
|
# Columns added to tables that already existed, and therefore already had rows.
|
|
# These are the interesting half: a new *table* is empty by definition, but a
|
|
# new *column* has to arrive beside data somebody cares about.
|
|
OLD_COLUMNS = (
|
|
("ssh_profiles", "resolves_here"),
|
|
("chats", "parent_chat_id"),
|
|
("chats", "unattended"),
|
|
("reports", "unread_notified"),
|
|
("groups", "limits_json"),
|
|
# What the other models are told about this one. A Text column with a scalar
|
|
# default, so the backfill is the easy kind -- listed because the hard kind
|
|
# (`reasoning_efforts`, below) was not caught by anything until it broke a
|
|
# live instance, and a column absent from this list is a column the migration
|
|
# tests do not exercise.
|
|
("models", "notes"),
|
|
)
|
|
|
|
|
|
def _rollback_to_0_8_1(engine) -> None:
|
|
"""Take a current database back to the shape 0.8.1 left behind.
|
|
|
|
Backwards rather than forwards because the alternative is importing two
|
|
versions of the models into one interpreter, which is not a thing Python
|
|
will do -- `lembas.db.models` is already imported and registered on one
|
|
`Base.metadata`. Dropping what was added produces the same *shape* the
|
|
differ has to repair, which is what is under test.
|
|
"""
|
|
with engine.begin() as connection:
|
|
for table in OLD_TABLES:
|
|
connection.execute(text(f"DROP TABLE IF EXISTS {table}"))
|
|
for table, column in OLD_COLUMNS:
|
|
connection.execute(text(f"ALTER TABLE {table} DROP COLUMN {column}"))
|
|
|
|
|
|
def _columns(engine, table: str) -> set[str]:
|
|
return {column["name"] for column in inspect(engine).get_columns(table)}
|
|
|
|
|
|
def test_the_recorded_shape_is_still_real():
|
|
"""The rollback above names tables and columns by hand, and a name that has
|
|
since been removed or renamed would make it silently upgrade nothing --
|
|
a test that passes because it tested an empty set.
|
|
"""
|
|
tables = Base.metadata.tables
|
|
|
|
for table in OLD_TABLES:
|
|
assert table in tables, f"{table} is no longer a table; fix OLD_TABLES"
|
|
for table, column in OLD_COLUMNS:
|
|
assert table in tables, table
|
|
assert column in tables[table].c, f"{table}.{column} is gone; fix OLD_COLUMNS"
|
|
|
|
|
|
def test_an_0_8_1_database_with_data_upgrades(db):
|
|
"""The whole point. Rows written before the upgrade must still be there
|
|
afterwards, with their values, and the new columns must exist beside them.
|
|
|
|
A failure here is somebody's instance not starting after pressing Update,
|
|
or -- worse and quieter -- starting with a column that silently reads NULL.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
# Data first, while the schema still has the columns the ORM expects.
|
|
from lembas.db.models import Chat, Group, User
|
|
|
|
owner = User(name="Frodo", email="f@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
group = Group(name="Fellowship")
|
|
db.add(group)
|
|
db.commit()
|
|
chat = Chat(user_id=owner.id, model_id="mithril", title="A chat from before")
|
|
db.add(chat)
|
|
db.commit()
|
|
chat_id, owner_id, group_id = chat.id, owner.id, group.id
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
|
|
# Confirm the rollback actually removed things, or the assertions below
|
|
# would pass against a database that was never old.
|
|
assert "chunks" not in inspect(engine).get_table_names()
|
|
assert "unattended" not in _columns(engine, "chats")
|
|
|
|
changes = sync_schema(engine)
|
|
|
|
assert changes, "the differ reported nothing to do on an old database"
|
|
for table in OLD_TABLES:
|
|
assert table in inspect(engine).get_table_names(), table
|
|
for table, column in OLD_COLUMNS:
|
|
assert column in _columns(engine, table), f"{table}.{column}"
|
|
|
|
# And the rows are still what they were.
|
|
with engine.connect() as connection:
|
|
row = connection.execute(
|
|
text("SELECT title, model_id, user_id FROM chats WHERE id = :id"),
|
|
{"id": chat_id},
|
|
).one()
|
|
assert row.title == "A chat from before"
|
|
assert row.model_id == "mithril"
|
|
assert row.user_id == owner_id
|
|
assert (
|
|
connection.execute(
|
|
text("SELECT name FROM groups WHERE id = :id"), {"id": group_id}
|
|
).scalar()
|
|
== "Fellowship"
|
|
)
|
|
|
|
|
|
def test_a_nullable_column_arrives_empty_rather_than_defaulted(db):
|
|
"""A nullable column is added with **no** default, so an existing row reads
|
|
NULL -- the value the model treats as absent.
|
|
|
|
An earlier version defaulted every column by type, which meant an added
|
|
foreign key arrived as `""` on old rows, and every "is this set?" check
|
|
downstream was wrong about them. `parent_chat_id` is exactly that shape: a
|
|
chat that predates subagents is not a helper, and `""` would not say so.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
from lembas.db.models import Chat, User
|
|
|
|
owner = User(name="Sam", email="s@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
chat = Chat(user_id=owner.id, model_id="m")
|
|
db.add(chat)
|
|
db.commit()
|
|
chat_id = chat.id
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
with engine.connect() as connection:
|
|
parent = connection.execute(
|
|
text("SELECT parent_chat_id FROM chats WHERE id = :id"), {"id": chat_id}
|
|
).scalar()
|
|
assert parent is None, "an added foreign key must be absent, not empty"
|
|
|
|
|
|
def test_a_not_null_column_backfills_every_existing_row(db):
|
|
"""SQLite refuses to add a NOT NULL column without a default, so one is
|
|
derived from the type. `chats.unattended` is a boolean: an old row has to
|
|
come back False rather than NULL, or every `if chat.unattended` downstream
|
|
reads a null as a value.
|
|
"""
|
|
engine = get_engine()
|
|
|
|
from lembas.db.models import Chat, User
|
|
|
|
owner = User(name="Merry", email="m@shire.test", password_hash="x") # noqa: S106
|
|
db.add(owner)
|
|
db.commit()
|
|
db.add(Chat(user_id=owner.id, model_id="m"))
|
|
db.commit()
|
|
db.close()
|
|
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
with engine.connect() as connection:
|
|
values = connection.execute(text("SELECT unattended FROM chats")).scalars().all()
|
|
assert values, "no rows survived the upgrade"
|
|
assert all(value in (0, False) for value in values), values
|
|
|
|
|
|
def test_upgrading_twice_changes_nothing_the_second_time(db):
|
|
"""It runs on every startup and has to converge. A second pass reporting
|
|
work would mean it was re-adding something, which on SQLite is an error that
|
|
stops the application booting."""
|
|
engine = get_engine()
|
|
_rollback_to_0_8_1(engine)
|
|
|
|
assert sync_schema(engine)
|
|
assert sync_schema(engine) == []
|
|
|
|
|
|
def test_the_search_indexes_are_rebuilt_when_missing(db):
|
|
"""FTS5 tables are not SQLAlchemy models, so `create_all` cannot make them
|
|
and the column differ cannot see them. `ensure_fts` is what converges them,
|
|
and it runs at every startup for the same reason the column sync does --
|
|
an instance whose indexes were dropped must repair itself rather than
|
|
return nothing and call it an empty library.
|
|
"""
|
|
engine = get_engine()
|
|
with engine.begin() as connection:
|
|
connection.execute(text("DROP TABLE IF EXISTS documents_fts"))
|
|
|
|
assert "documents_fts" in ensure_fts(engine)
|
|
assert "documents_fts" in inspect(engine).get_table_names()
|
|
# Converges: a second call has nothing left to make.
|
|
assert ensure_fts(engine) == []
|
|
|
|
|
|
@pytest.mark.parametrize("table", OLD_TABLES)
|
|
def test_each_new_table_is_usable_after_the_upgrade(db, table):
|
|
"""Created is not the same as correct. A table the differ made but got the
|
|
columns wrong on fails at the first insert, which is the first *reply* on an
|
|
upgraded instance rather than at startup."""
|
|
engine = get_engine()
|
|
_rollback_to_0_8_1(engine)
|
|
sync_schema(engine)
|
|
|
|
declared = {column.name for column in Base.metadata.tables[table].c}
|
|
assert _columns(engine, table) == declared
|
|
|
|
|
|
# --- A list-shaped JSON column added to a database that already had rows -----
|
|
#
|
|
# Reported as a 500 on a live instance the moment it updated:
|
|
#
|
|
# ValueError: Attribute 'reasoning_efforts' does not accept objects
|
|
# of type <class 'dict'>
|
|
#
|
|
# `_literal_default` read the shape off `column.type.python_type`, and
|
|
# `MutableList.as_mutable(JSON)` returns the *same* JSON type object with a
|
|
# listener attached -- it does not subclass it -- so `python_type` is `dict` for
|
|
# both flavours. Every existing row got `'{}'` in a list column, and MutableList
|
|
# refuses a dict while *loading*, so every page that listed models raised.
|
|
#
|
|
# The suite never caught it because `conftest.py` builds a fresh database, where
|
|
# the column is created from the model rather than backfilled by a migration.
|
|
# These tests exercise the path that actually ran.
|
|
def test_a_list_column_is_backfilled_with_a_list():
|
|
from lembas.db.migrations import _default_shape, _literal_default
|
|
from lembas.db.models import Model
|
|
|
|
columns = {c.name: c for c in Model.__table__.columns}
|
|
assert _default_shape(columns["reasoning_efforts"]) is list
|
|
assert _literal_default(columns["reasoning_efforts"]) == "'[]'"
|
|
|
|
|
|
def test_a_dict_column_still_gets_a_dict():
|
|
from lembas.db.migrations import _literal_default
|
|
from lembas.db.models import Model
|
|
|
|
columns = {c.name: c for c in Model.__table__.columns}
|
|
assert _literal_default(columns["capabilities_json"]) == "'{}'"
|
|
assert _literal_default(columns["params_json"]) == "'{}'"
|
|
|
|
|
|
def _seed_model(engine, **overrides):
|
|
"""A real row, made the way the application makes one.
|
|
|
|
Built through the ORM rather than a hand-written INSERT: the table has
|
|
several NOT NULL columns and a test that enumerates them is a test that
|
|
breaks every time one is added, for reasons having nothing to do with what
|
|
it is checking.
|
|
"""
|
|
from sqlalchemy.orm import Session
|
|
|
|
from lembas.db.models import Connection, Model
|
|
|
|
with Session(engine) as session:
|
|
connection = Connection(
|
|
name="local", base_url="http://127.0.0.1:1", api_key_encrypted=""
|
|
)
|
|
session.add(connection)
|
|
session.flush()
|
|
model = Model(connection_id=connection.id, model_id="bonsai", **overrides)
|
|
session.add(model)
|
|
session.commit()
|
|
return model.id
|
|
|
|
|
|
def test_the_damage_already_written_is_repaired_on_start(tmp_path):
|
|
"""The fix to `_literal_default` helps the next instance. This is the one
|
|
that helps the instance that has already updated."""
|
|
from sqlalchemy import create_engine, text
|
|
|
|
from lembas.db.migrations import repair_json_shapes, sync_schema
|
|
|
|
engine = create_engine(f"sqlite:///{tmp_path}/repair.db")
|
|
sync_schema(engine)
|
|
model_id = _seed_model(engine)
|
|
|
|
# Exactly what the broken backfill left behind on a row that predated the
|
|
# column: the wrong empty value, in a column that refuses it on load.
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("UPDATE models SET reasoning_efforts = '{}' WHERE id = :id"),
|
|
{"id": model_id},
|
|
)
|
|
|
|
assert repair_json_shapes(engine)
|
|
|
|
with engine.begin() as connection:
|
|
stored = connection.execute(
|
|
text("SELECT reasoning_efforts FROM models WHERE id = :id"), {"id": model_id}
|
|
).scalar()
|
|
assert stored == "[]"
|
|
|
|
# And the row loads again, which is the whole point -- the failure was a
|
|
# ValueError while reading, not a wrong value sitting harmlessly.
|
|
from sqlalchemy.orm import Session
|
|
|
|
from lembas.db.models import Model
|
|
|
|
with Session(engine) as session:
|
|
assert session.get(Model, model_id).reasoning_efforts == []
|
|
|
|
# Converges: a second run finds nothing left to do.
|
|
assert repair_json_shapes(engine) == []
|
|
|
|
|
|
def test_the_repair_leaves_a_dict_column_alone(tmp_path):
|
|
"""`{}` is a legitimate value in a MutableDict column and must survive."""
|
|
from sqlalchemy import create_engine, text
|
|
|
|
from lembas.db.migrations import repair_json_shapes, sync_schema
|
|
|
|
engine = create_engine(f"sqlite:///{tmp_path}/keep.db")
|
|
sync_schema(engine)
|
|
model_id = _seed_model(engine)
|
|
|
|
with engine.begin() as connection:
|
|
connection.execute(
|
|
text("UPDATE models SET capabilities_json = '{}' WHERE id = :id"),
|
|
{"id": model_id},
|
|
)
|
|
repair_json_shapes(engine)
|
|
with engine.begin() as connection:
|
|
stored = connection.execute(
|
|
text("SELECT capabilities_json FROM models WHERE id = :id"), {"id": model_id}
|
|
).scalar()
|
|
assert stored == "{}"
|