From e6dd88358096ed47e88a390413a6804fd7bbba70 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 01:41:02 +0000 Subject: [PATCH 1/4] add status_message column to installed_apps First migration after the init schema. Establishes the yoyo -- depends: naming convention (shard-core-NNNN-). Column is nullable so it applies cleanly to existing databases and defaults to no message. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/shard-core-0002-app-status-message.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 migrations/shard-core-0002-app-status-message.sql 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; From 92ea3b310c8ee62f33fbe1c1cfe1423e1957a287 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 01:41:26 +0000 Subject: [PATCH 2/4] persist app ERROR cause in status_message and expose via API update_app_status now writes the message to the new status_message column when a status becomes ERROR, and clears it (NULL) on any other transition. The InstalledApp model gains a status_message field, so both GET /protected/apps and GET /protected/apps/{name} return the reason, which now survives a page reload instead of only reaching the UI via the transient on_app_install_error websocket event. Co-Authored-By: Claude Opus 4.8 (1M context) --- shard_core/data_model/app_meta.py | 1 + shard_core/database/installed_apps.py | 13 ++++++++----- shard_core/service/app_installation/util.py | 5 ++++- 3 files changed, 13 insertions(+), 6 deletions(-) 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..5fecd983 100644 --- a/shard_core/database/installed_apps.py +++ b/shard_core/database/installed_apps.py @@ -28,12 +28,15 @@ async def insert(conn: AsyncConnection, app: dict) -> dict: return await cur.fetchone() -async def update_status(conn: AsyncConnection, name: str, status: str) -> dict | None: - sql: LiteralString = ( - "UPDATE installed_apps SET status = %(status)s WHERE name = %(name)s RETURNING *" - ) +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, 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( From 5fb5f54235f1709f6cd36092d318fcce6d30904c Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 01:43:18 +0000 Subject: [PATCH 3/4] add tests for status_message persistence and document migration naming Covers: ERROR message persisted, cleared on leaving ERROR, ignored for non-ERROR status, and still returned by GET /protected/apps/{name} after a reload. Documents the shard-core-NNNN- yoyo migration convention in agents.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- agents.md | 2 +- shard_core/database/installed_apps.py | 4 +- tests/test_app_status_message.py | 67 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/test_app_status_message.py 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/shard_core/database/installed_apps.py b/shard_core/database/installed_apps.py index 5fecd983..46f874ed 100644 --- a/shard_core/database/installed_apps.py +++ b/shard_core/database/installed_apps.py @@ -31,7 +31,9 @@ async def insert(conn: AsyncConnection, app: dict) -> dict: 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, status_message = %(status_message)s WHERE name = %(name)s RETURNING *" + sql: LiteralString = ( + "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, diff --git a/tests/test_app_status_message.py b/tests/test_app_status_message.py new file mode 100644 index 00000000..b4c1a5f6 --- /dev/null +++ b/tests/test_app_status_message.py @@ -0,0 +1,67 @@ +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.STOPPED, message="not an error") + + assert (await _get_app("app_c")).status_message is None + + +async def test_error_message_survives_reload_via_api(api_client: AsyncClient): + await _insert_app("app_d") + await update_app_status("app_d", Status.ERROR, message="install failed: boom") + + response = await api_client.get("protected/apps/app_d") + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["status"] == Status.ERROR + assert body["status_message"] == "install failed: boom" From 00542b144e3f7b4f9441cb4827c9597e5a4a4ca5 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 22 Jul 2026 01:56:21 +0000 Subject: [PATCH 4/4] strengthen status_message tests per review - test_message_ignored_for_non_error_status now pre-seeds an ERROR message so it proves the STOPPED transition actively clears it, not just that a message was never set. - API round-trip test now also asserts the list endpoint (GET /protected/apps), which the issue names explicitly, and uses the lighter app_client fixture (no Docker/lifespan) instead of api_client. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_app_status_message.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_app_status_message.py b/tests/test_app_status_message.py index b4c1a5f6..e4e96a15 100644 --- a/tests/test_app_status_message.py +++ b/tests/test_app_status_message.py @@ -49,19 +49,23 @@ async def test_message_is_cleared_when_leaving_error(db): 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(api_client: AsyncClient): +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") - response = await api_client.get("protected/apps/app_d") + 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" - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["status"] == Status.ERROR - assert body["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"