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
2 changes: 1 addition & 1 deletion agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>.sql` with a `-- depends: <previous>` 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")
Expand Down
4 changes: 4 additions & 0 deletions migrations/shard-core-0002-app-status-message.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions shard_core/data_model/app_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
11 changes: 8 additions & 3 deletions shard_core/database/installed_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
5 changes: 4 additions & 1 deletion shard_core/service/app_installation/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
71 changes: 71 additions & 0 deletions tests/test_app_status_message.py
Original file line number Diff line number Diff line change
@@ -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"
Loading