diff --git a/agents.md b/agents.md index f867d737..9a012e25 100644 --- a/agents.md +++ b/agents.md @@ -64,7 +64,7 @@ just get-types # Sync data models from freeshard-controller repo ## Key Patterns ### Database Access -PostgreSQL via psycopg3 async. All DB functions take `conn: AsyncConnection` as first arg. Callers acquire connections via `async with db_conn() as conn:`. Schema managed by yoyo-migrations in `migrations/`. +PostgreSQL via psycopg3 async. All DB functions take `conn: AsyncConnection` as first arg. Callers acquire connections via `async with db_conn() as conn:`. Schema managed by yoyo-migrations in `migrations/`, named `shard-core-NNNN-.sql` with a `-- depends: ` header chaining each migration to the one before it. ```python async with db_conn() as conn: app = await db_installed_apps.get_by_name(conn, "myapp") diff --git a/migrations/shard-core-0002-app-status-message.sql b/migrations/shard-core-0002-app-status-message.sql new file mode 100644 index 00000000..ea8d2398 --- /dev/null +++ b/migrations/shard-core-0002-app-status-message.sql @@ -0,0 +1,4 @@ +-- shard-core-0002-app-status-message +-- depends: shard-core-0001-init + +ALTER TABLE installed_apps ADD COLUMN IF NOT EXISTS status_message TEXT; diff --git a/shard_core/data_model/app_meta.py b/shard_core/data_model/app_meta.py index 44cbf5e0..a9cae5c6 100644 --- a/shard_core/data_model/app_meta.py +++ b/shard_core/data_model/app_meta.py @@ -157,6 +157,7 @@ class InstalledApp(BaseModel): name: str installation_reason: InstallationReason = InstallationReason.UNKNOWN status: str = Status.UNKNOWN + status_message: Optional[str] = None last_access: Optional[datetime.datetime] = None diff --git a/shard_core/database/installed_apps.py b/shard_core/database/installed_apps.py index f8084945..46f874ed 100644 --- a/shard_core/database/installed_apps.py +++ b/shard_core/database/installed_apps.py @@ -28,12 +28,17 @@ async def insert(conn: AsyncConnection, app: dict) -> dict: return await cur.fetchone() -async def update_status(conn: AsyncConnection, name: str, status: str) -> dict | None: +async def update_status( + conn: AsyncConnection, name: str, status: str, status_message: str | None = None +) -> dict | None: sql: LiteralString = ( - "UPDATE installed_apps SET status = %(status)s WHERE name = %(name)s RETURNING *" + "UPDATE installed_apps SET status = %(status)s, status_message = %(status_message)s WHERE name = %(name)s RETURNING *" ) async with conn.cursor(row_factory=dict_row) as cur: - await cur.execute(sql, {"name": name, "status": status}) + await cur.execute( + sql, + {"name": name, "status": status, "status_message": status_message}, + ) return await cur.fetchone() diff --git a/shard_core/service/app_installation/util.py b/shard_core/service/app_installation/util.py index a616746d..c1da6a0b 100644 --- a/shard_core/service/app_installation/util.py +++ b/shard_core/service/app_installation/util.py @@ -53,8 +53,11 @@ def assert_app_status(installed_app: InstalledApp, *allowed_status: Status): async def update_app_status(app_name: str, status: Status, message: str | None = None): + status_message = message if status == Status.ERROR else None async with db_conn() as conn: - result = await db_installed_apps.update_status(conn, app_name, status) + result = await db_installed_apps.update_status( + conn, app_name, status, status_message + ) if not result: raise KeyError(app_name) log.debug( diff --git a/tests/test_app_status_message.py b/tests/test_app_status_message.py new file mode 100644 index 00000000..e4e96a15 --- /dev/null +++ b/tests/test_app_status_message.py @@ -0,0 +1,71 @@ +import pytest +from fastapi import status +from httpx import AsyncClient + +from shard_core.data_model.app_meta import InstallationReason, InstalledApp, Status +from shard_core.database import installed_apps as db_installed_apps +from shard_core.database.connection import db_conn +from shard_core.service.app_installation.util import update_app_status + +pytest_plugins = ("pytest_asyncio",) +pytestmark = pytest.mark.asyncio + + +async def _insert_app(name: str): + async with db_conn() as conn: + await db_installed_apps.insert( + conn, + InstalledApp( + name=name, + installation_reason=InstallationReason.CUSTOM, + status=Status.INSTALLING, + ).model_dump(), + ) + + +async def _get_app(name: str) -> InstalledApp: + async with db_conn() as conn: + return InstalledApp.model_validate( + await db_installed_apps.get_by_name(conn, name) + ) + + +async def test_error_message_is_persisted(db): + await _insert_app("app_a") + + await update_app_status("app_a", Status.ERROR, message="boom") + + assert (await _get_app("app_a")).status_message == "boom" + + +async def test_message_is_cleared_when_leaving_error(db): + await _insert_app("app_b") + await update_app_status("app_b", Status.ERROR, message="boom") + + await update_app_status("app_b", Status.STOPPED) + + assert (await _get_app("app_b")).status_message is None + + +async def test_message_ignored_for_non_error_status(db): + await _insert_app("app_c") + await update_app_status("app_c", Status.ERROR, message="boom") + + await update_app_status("app_c", Status.STOPPED, message="not an error") + + assert (await _get_app("app_c")).status_message is None + + +async def test_error_message_survives_reload_via_api(app_client: AsyncClient): + await _insert_app("app_d") + await update_app_status("app_d", Status.ERROR, message="install failed: boom") + + single = await app_client.get("protected/apps/app_d") + assert single.status_code == status.HTTP_200_OK + assert single.json()["status"] == Status.ERROR + assert single.json()["status_message"] == "install failed: boom" + + listing = await app_client.get("protected/apps") + assert listing.status_code == status.HTTP_200_OK + app_d = next(a for a in listing.json() if a["name"] == "app_d") + assert app_d["status_message"] == "install failed: boom"