Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
10 changes: 10 additions & 0 deletions backend/druks/contrib/chat/app.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions backend/druks/contrib/chat/enums.py
Original file line number Diff line number Diff line change
@@ -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"
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -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")
87 changes: 87 additions & 0 deletions backend/druks/contrib/chat/models.py
Original file line number Diff line number Diff line change
@@ -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))
17 changes: 17 additions & 0 deletions backend/druks/sandbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ async def provision(
) as host:
return host

async def set_expiry(self, *, host_id: str, expires_at: datetime) -> None:
"""Move a host's lease to ``expires_at`` without touching the VM.

Clipping the expiry down is how an idle hold ends: the host keeps
running until the new expiry, and drukbox's janitor reaps it then,
so druks needs no reconciler of its own. ``release`` remains the
hard delete for callers that want the VM gone now.

A host the control plane no longer knows about raises the SDK's
``SandboxNotFoundError`` — it is not swallowed here.
"""
api = self._api()
try:
await api.renew_host(host_id, expires_at=expires_at)
finally:
await api.aclose()

async def release(self, *, host_id: str) -> None:
"""Terminate the VM. Idempotent and infallible — already-gone hosts
no-op silently; any other failure is logged but not surfaced so
Expand Down
Empty file.
52 changes: 52 additions & 0 deletions backend/tests/chat/test_models.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def browser_session_declarations():
"test_provider_login_persistence",
"test_app_migrations",
"test_proof_app_migration",
"test_chat_migration",
}


Expand Down
1 change: 1 addition & 0 deletions backend/tests/test_app_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions backend/tests/test_app_roster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
# Derived from the landing page the app declares, labelled by that page.
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
48 changes: 48 additions & 0 deletions backend/tests/test_chat_migration.py
Original file line number Diff line number Diff line change
@@ -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()
Loading