diff --git a/backend/druks/contrib/chat/__init__.py b/backend/druks/contrib/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/chat/app.py b/backend/druks/contrib/chat/app.py new file mode 100644 index 00000000..a1d94050 --- /dev/null +++ b/backend/druks/contrib/chat/app.py @@ -0,0 +1,10 @@ +from druks.apps import App + + +class Chat(App): + name = "chat" + icon = "message-square" + description = "Operator conversations this appliance owns — several threads, one account each." + # Every table this app owns carries the ``chat_`` prefix, so the thread's + # schema can never collide with core's or another app's. + prefix_tables = True diff --git a/backend/druks/contrib/chat/enums.py b/backend/druks/contrib/chat/enums.py new file mode 100644 index 00000000..4b7898c8 --- /dev/null +++ b/backend/druks/contrib/chat/enums.py @@ -0,0 +1,19 @@ +from enum import StrEnum + + +class Autonomy(StrEnum): + """How far a conversation's next agent call may go. The setting is the + conversation's, not the turn's, so a mode change applies to the next call.""" + + PROPOSE = "propose" + CONFIRM = "confirm" + FULL = "full" + + +class Role(StrEnum): + """Who wrote a line on the thread. Closed: a column can never hold a speaker + no screen knows how to render.""" + + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" diff --git a/backend/druks/contrib/chat/migrations/__init__.py b/backend/druks/contrib/chat/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/chat/migrations/versions/__init__.py b/backend/druks/contrib/chat/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/druks/contrib/chat/migrations/versions/chat_0001_conversations_messages.py b/backend/druks/contrib/chat/migrations/versions/chat_0001_conversations_messages.py new file mode 100644 index 00000000..562e1258 --- /dev/null +++ b/backend/druks/contrib/chat/migrations/versions/chat_0001_conversations_messages.py @@ -0,0 +1,46 @@ +"""chat: conversations and messages + +Revision ID: chat_0001 +Revises: +Create Date: 2026-09-04 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# This app owns an independent migration history — its own +# alembic_version_chat table, never linked to core's revisions. +revision = "chat_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "chat_conversations", + # Integer subject key (StoredSubject.id) — serial, matching create_all. + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("account_id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("autonomy", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "chat_messages", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("conversation_id", sa.Integer(), nullable=False), + sa.Column("role", sa.String(), nullable=False), + sa.Column("body", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["conversation_id"], ["chat_conversations.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("chat_messages") + op.drop_table("chat_conversations") diff --git a/backend/druks/contrib/chat/models.py b/backend/druks/contrib/chat/models.py new file mode 100644 index 00000000..d1cda2cb --- /dev/null +++ b/backend/druks/contrib/chat/models.py @@ -0,0 +1,87 @@ +from datetime import datetime + +from sqlalchemy import ForeignKey, select +from sqlalchemy.orm import Mapped, mapped_column + +from druks.contrib.chat.enums import Autonomy, Role +from druks.db import Base, StoredSubject, db_session + + +class Conversation(StoredSubject): + __tablename__ = "chat_conversations" + + # id: the integer subject key inherited from StoredSubject; the class name + # derives subject_type "conversation". + account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="RESTRICT")) + title: Mapped[str] + # Autonomy is a String column driven by this app's closed StrEnum, not a + # native PG enum: the modes stay in code and a rename never needs ALTER TYPE. + autonomy: Mapped[str] = mapped_column(default=Autonomy.PROPOSE) + created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) + + @classmethod + async def create( + cls, + *, + account_id: str, + title: str, + autonomy: Autonomy = Autonomy.PROPOSE, + ) -> "Conversation": + session = db_session() + conversation = cls(account_id=account_id, title=title, autonomy=autonomy) + session.add(conversation) + await session.flush() + return conversation + + @classmethod + async def get(cls, conversation_id: int) -> "Conversation | None": + return await db_session().get(cls, conversation_id) + + @classmethod + async def list_for_account(cls, account_id: str) -> list["Conversation"]: + """This account's threads, newest first. A conversation belongs to one + operator; another account's list never includes it.""" + statement = ( + select(cls) + .where(cls.account_id == account_id) + .order_by(cls.created_at.desc(), cls.id.desc()) + ) + return list(await db_session().scalars(statement)) + + async def add_message(self, *, role: Role, body: str) -> "Message": + return await Message.create(conversation_id=self.id, role=role, body=body) + + async def list_messages(self) -> list["Message"]: + return await Message.list_for_conversation(self.id) + + +class Message(Base): + __tablename__ = "chat_messages" + + # A row, not an event and not a StoredSubject: events stay facts about what + # happened, while a message is the thread the conversation reads back in + # order. Issues' ``Comment`` is the same shape. + id: Mapped[int] = mapped_column(primary_key=True) + conversation_id: Mapped[int] = mapped_column(ForeignKey("chat_conversations.id")) + role: Mapped[str] + body: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) + + @classmethod + async def create(cls, *, conversation_id: int, role: Role, body: str) -> "Message": + session = db_session() + message = cls(conversation_id=conversation_id, role=role, body=body) + session.add(message) + await session.flush() + return message + + @classmethod + async def list_for_conversation(cls, conversation_id: int) -> list["Message"]: + """The thread, oldest first — a conversation reads down. A conversation + nobody has spoken on is an empty list, never None.""" + statement = ( + select(cls) + .where(cls.conversation_id == conversation_id) + .order_by(cls.created_at, cls.id) + ) + return list(await db_session().scalars(statement)) diff --git a/backend/tests/chat/__init__.py b/backend/tests/chat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/chat/test_models.py b/backend/tests/chat/test_models.py new file mode 100644 index 00000000..641b6fcc --- /dev/null +++ b/backend/tests/chat/test_models.py @@ -0,0 +1,52 @@ +from druks.accounts.models import Account +from druks.apps.loader import get_app +from druks.contrib.chat.enums import Autonomy, Role +from druks.contrib.chat.models import Conversation, Message + + +def test_chat_app_is_bundled_with_prefixed_tables(): + app = get_app("chat") + assert app.name == "chat" + assert app.prefix_tables is True + assert app.table_prefix == "chat_" + + +async def test_list_for_account_excludes_another_operators_threads(): + owner = await Account.get_or_create("op@example.com") + other = await Account.get_or_create("dev@example.com") + mine = await Conversation.create(account_id=owner.id, title="mine") + await Conversation.create(account_id=other.id, title="theirs") + + listed = await Conversation.list_for_account(owner.id) + assert [conversation.id for conversation in listed] == [mine.id] + assert listed[0].title == "mine" + assert listed[0].autonomy == Autonomy.PROPOSE + assert listed[0].account_id == owner.id + + +async def test_conversations_list_newest_first(): + account = await Account.get_or_create("op@example.com") + first = await Conversation.create(account_id=account.id, title="first") + second = await Conversation.create( + account_id=account.id, title="second", autonomy=Autonomy.FULL + ) + + listed = await Conversation.list_for_account(account.id) + assert [conversation.id for conversation in listed] == [second.id, first.id] + assert listed[0].autonomy == Autonomy.FULL + + +async def test_messages_are_rows_and_empty_is_a_list(): + account = await Account.get_or_create("op@example.com") + conversation = await Conversation.create(account_id=account.id, title="quiet") + + assert await conversation.list_messages() == [] + + first = await conversation.add_message(role=Role.USER, body="hello") + second = await conversation.add_message(role=Role.ASSISTANT, body="hi") + listed = await conversation.list_messages() + assert [message.body for message in listed] == ["hello", "hi"] + assert [message.role for message in listed] == [Role.USER, Role.ASSISTANT] + assert listed[0].id == first.id + assert listed[1].id == second.id + assert all(isinstance(message, Message) for message in listed) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5adc7309..58a34d30 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -163,6 +163,7 @@ def browser_session_declarations(): "test_provider_subscription_persistence", "test_app_migrations", "test_proof_app_migration", + "test_chat_migration", } diff --git a/backend/tests/test_app_loader.py b/backend/tests/test_app_loader.py index d912b598..bde308c6 100644 --- a/backend/tests/test_app_loader.py +++ b/backend/tests/test_app_loader.py @@ -11,6 +11,7 @@ def test_import_app_models_registers_software_factory_via_generic_discovery(): from druks.models import Base assert get_app("software_factory").prefix_tables is False + assert get_app("chat").prefix_tables is True import_app_models() # idempotent; raises if the unprefixed tables aren't exempt assert {"projects", "work_items", "project_repos"} <= set(Base.metadata.tables) diff --git a/backend/tests/test_app_roster.py b/backend/tests/test_app_roster.py index e8229274..3ffdeb8e 100644 --- a/backend/tests/test_app_roster.py +++ b/backend/tests/test_app_roster.py @@ -15,6 +15,13 @@ def test_roster_lists_installed_apps_with_subject_types(tmp_path: Path): # Software Factory's pages are React, so its tabs live in its frontend. assert software_factory["navigation"] == [] assert software_factory["icon"] + chat = roster["chat"] + assert chat["builtin"] is False + assert chat["hasFrontend"] is False + assert chat["icon"] == "message-square" + # No workflow yet — subjects follow from workflows, not from StoredSubject alone. + assert chat["subjectTypes"] == [] + field_notes = roster["field_notes"] assert field_notes["subjectTypes"] == ["note", "repository"] # Derived from the landing page the app declares, labelled by that page. diff --git a/backend/tests/test_apps.py b/backend/tests/test_apps.py index 4281be1e..e452f108 100644 --- a/backend/tests/test_apps.py +++ b/backend/tests/test_apps.py @@ -24,7 +24,7 @@ def _subjects(cls) -> list[type[Subject]]: def test_iter_apps_discovers_the_bundled_apps(): """The bundled apps resolve from the ``druks.apps`` entry points.""" - assert {app.name for app in iter_apps()} >= {"core", "software_factory", "usage"} + assert {app.name for app in iter_apps()} >= {"core", "software_factory", "chat", "usage"} def test_platform_apps_are_builtin(): diff --git a/backend/tests/test_chat_migration.py b/backend/tests/test_chat_migration.py new file mode 100644 index 00000000..e312aa3c --- /dev/null +++ b/backend/tests/test_chat_migration.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from alembic import command +from alembic.config import Config +from druks.testing import TEST_DATABASE_URL, init_db +from sqlalchemy import create_engine + +_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "alembic.ini" +_VERSIONS = ( + Path(__file__).resolve().parent.parent + / "druks" + / "contrib" + / "chat" + / "migrations" + / "versions" +) +_TABLES = "chat_messages, chat_conversations, alembic_version_chat" + + +def _config() -> Config: + config = Config(str(_ALEMBIC_INI)) + config.set_main_option("version_locations", str(_VERSIONS)) + config.set_main_option("sqlalchemy.url", TEST_DATABASE_URL) + config.attributes["version_table"] = "alembic_version_chat" + return config + + +def _drop(conn) -> None: + conn.exec_driver_sql(f"DROP TABLE IF EXISTS {_TABLES}") + + +def test_chat_migration_applies_under_its_own_version_table(request): + engine = create_engine(TEST_DATABASE_URL, isolation_level="AUTOCOMMIT") + with engine.connect() as conn: + _drop(conn) + try: + command.upgrade(_config(), "head") + with engine.connect() as conn: + assert conn.exec_driver_sql("SELECT to_regclass('chat_conversations')").scalar() + assert conn.exec_driver_sql("SELECT to_regclass('chat_messages')").scalar() + head = conn.exec_driver_sql("SELECT version_num FROM alembic_version_chat").scalar() + assert head == "chat_0001" + finally: + with engine.connect() as conn: + _drop(conn) + init_db(engine) + engine.dispose() + request.getfixturevalue("_druks_engine").dispose() diff --git a/pyproject.toml b/pyproject.toml index 0978e5d4..84820392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ druks = "druks.testing" [project.entry-points."druks.apps"] core = "druks.core.app:Core" software_factory = "druks.contrib.software_factory.app:SoftwareFactory" +chat = "druks.contrib.chat.app:Chat" usage = "druks.usage.app:Usage" [build-system]