Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/on-pull-request-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
- "backend/**"
- "pyproject.toml"
- "uv.lock"
# The page wire snapshot is shared: the backend suite compares what the
# proof app serializes against the file the renderer reads, so a rename on
# the frontend side has to run this suite too.
- "frontend/src/api/types.ts"
- "frontend/src/druksui/**"

concurrency:
group: on-pull-request-backend-${{ github.event.pull_request.number }}
Expand Down
7 changes: 7 additions & 0 deletions backend/druks/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from druks.services.routes import router as service_identities_router
from druks.settings import Settings, ensure_data_dirs, load_settings, setup_logging
from druks.skills.routes import router as skills_router
from druks.ui.exceptions import PageReadError
from druks.user_settings.routes import router as settings_router
from druks.webhooks import router as webhooks_router

Expand Down Expand Up @@ -182,6 +183,12 @@ async def _agent_call_not_found_handler(request: Request, exc: AgentCallNotFound
return await _agent_api_error_handler(request, gate_errors.AgentCallNotFound(exc.agent_call_id))


@app.exception_handler(PageReadError)
async def _page_read_error_handler(request: Request, exc: PageReadError) -> JSONResponse:
logging.getLogger(__name__).exception("page read failed: %s", exc)
return JSONResponse(status_code=500, content={"error": "PAGE_FAILED", "detail": str(exc)})


# Auth-mode drift (e.g. none mode grew a second operator account) is an
# operator problem, not a caller problem: log it loudly, answer 503.
@app.exception_handler(AuthConfigurationError)
Expand Down
29 changes: 22 additions & 7 deletions backend/druks/apps/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from druks.events.models import Event
from druks.models import StoredSubject
from druks.ui.exceptions import PageRouteError
from druks.ui.exceptions import PageContractError, PageReadError, PageRouteError
from druks.user_settings.models import SettingsOverride

from .exceptions import AppRouteConflict, AppSubjectContractError, SettingsDeclarationError
Expand Down Expand Up @@ -405,7 +405,7 @@ def _get_page_routes(cls) -> "APIRouter":
# The landing page's route is "/", and its snapshot answers at
# the bare /pages.
declaration.route.rstrip("/"),
cls._page_endpoint(declaration.function, operations),
cls._page_endpoint(declaration, operations),
methods=["GET"],
response_model=Page,
response_model_by_alias=True,
Expand All @@ -414,15 +414,30 @@ def _get_page_routes(cls) -> "APIRouter":
return router

@classmethod
def _page_endpoint(cls, project, operations: "dict[str, Operation]"):
def _page_endpoint(cls, declaration: "PageRoute", operations: "dict[str, Operation]"):
"""``wraps`` keeps the page function's signature, so FastAPI still
validates every route parameter."""
from druks.ui import Page

@wraps(project)
@wraps(declaration.function)
async def read_page(**parameters):
page = await project(**parameters)
for action in page.iter_actions():
action.check_operation(cls.name, operations)
try:
page = await declaration.function(**parameters)
except Exception as error:
raise PageReadError(
cls.name, declaration.name, f"its own code raised {type(error).__name__}"
) from error
if not isinstance(page, Page):
raise PageContractError(
cls.name,
declaration.name,
f"it answered with {type(page).__name__}, not a Page",
)
try:
for action in page.iter_actions():
action.check_operation(cls.name, operations)
except ValueError as error:
raise PageContractError(cls.name, declaration.name, str(error)) from error
return page

return read_page
Expand Down
18 changes: 13 additions & 5 deletions backend/druks/scaffolding/app_template/AGENTS.md-tpl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ webhooks, and the dashboard.

- App contracts and the full author surface:
https://github.com/czpython/druks/blob/main/docs/writing-an-app.md.
- Pages, blocks, values, fields, actions, and liveness:
https://github.com/czpython/druks/blob/main/docs/druks-ui.md.
- A worked app to read alongside it:
https://github.com/czpython/druks/tree/main/backend/tests/druks-field_notes.
- Each stub module under `druks_{{ name }}/` documents its own role in comments.
Expand All @@ -18,13 +20,19 @@ webhooks, and the dashboard.
`[project.entry-points."druks.apps"]` key must equal `{{ name }}`. Boot fails
loudly on a mismatched key, a duplicate name, an import error, or an unprefixed
table.
- Druks discovers leaf modules named `workflows`, `routes`, `subscribers`, and
`webhooks`. A capability placed in `workflow.py` is never discovered. Any other
module name is inert until a discovered module imports it.
- Druks discovers leaf modules named `workflows`, `routes`, `pages`,
`subscribers`, and `webhooks`. A capability placed in `workflow.py` is never
discovered. Any other module name is inert until a discovered module imports it.
- Screens are Python. `pages.py` declares them and the dashboard renders them;
this app writes no JavaScript. An app that needs full control of its interface
ships an ESM frontend instead, which the author guide describes.
- A page function is a pure read. Druks reruns it on load, on an event, on a
reconnect, and on a retry, so it must never write, start work, publish an
event, answer a gate, or depend on process state.
- Import the concern namespaces — `druks.apps`, `druks.workflows`,
`druks.agents`, `druks.db`, `druks.schemas`, `druks.signals`, `druks.events`,
`druks.prompts`, `druks.webhooks`, `druks.testing` — never `druks.durable` or an
internal module. The root `druks` package exports only its version.
`druks.prompts`, `druks.ui`, `druks.webhooks`, `druks.testing` — never
`druks.durable` or an internal module. The root `druks` package exports only its version.
- Domain policy lives here. Generic agent, harness, workspace, sandbox, event, gate,
webhook, and settings plumbing belongs to Druks. When this app needs
something the author surface lacks, widen the primitive that already owns the
Expand Down
3 changes: 3 additions & 0 deletions backend/druks/scaffolding/app_template/package/app.py-tpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ class {{ Name }}(App):
# Any Lucide glyph name the frontend bundles ("telescope", "hammer", ...).
icon = "box"
description = "TODO: one-line blurb shown in the settings pane."
# The appbar tabs, as the names of pages declared in pages.py. Each must be
# a static top-level page; the tab wears that page's label.
navigation = ["overview"]

# Operator-tunable knobs go on an inner ``class Settings(AppSettings)``
# after importing AppSettings from druks.apps —
Expand Down

This file was deleted.

38 changes: 38 additions & 0 deletions backend/druks/scaffolding/app_template/package/pages.py-tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from druks import ui

# Every ``@ui.page`` here becomes a screen in the shared dashboard. A page
# function is a pure read: Druks reruns it on load, on an event, on a
# reconnect, and on a retry, so it must never write, start work, or answer a
# gate. The full block, value, and field catalog is in docs/druks-ui.md.


@ui.page("/")
async def overview():
return ui.Page(
"{{ Name }}",
description="TODO: what an operator comes here to see.",
blocks=[
ui.Stack(
[
ui.Text("This page is Python. The dashboard renders it."),
ui.Card(
title="Next",
blocks=[
ui.Facts(
[
ui.Fact(
"Pages",
value=ui.TextValue("druks_{{ name }}/pages.py"),
),
ui.Fact(
"State",
value=ui.StatusValue("scaffolded", tone="active"),
),
]
)
],
),
]
)
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ from druks_{{ name }}.workflows import Echo

async def test_echo_returns_its_message():
assert await run_workflow(Echo, message="hello") == "hello"


async def test_the_landing_page_renders():
from druks_{{ name }}.pages import overview

page = await overview.function()

assert page.title == "{{ Name }}"
assert page.blocks
4 changes: 4 additions & 0 deletions backend/druks/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ def pytest_configure(config) -> None:
including suites that never touch druks."""
global _discovery_error

# A Workflow resolves its declaring app at definition, so collection cannot
# import an app's workflows module before every installed package is claimed.
iter_apps()

with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as settings_file:
settings_file.write(
f'[secrets]\nsecrets_key = "{base64.b64encode(secrets.token_bytes(32)).decode()}"\n'
Expand Down
16 changes: 16 additions & 0 deletions backend/druks/ui/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@ class PageRouteError(Exception):
nested child, and at boot for a missing landing page, a repeated page name,
two routes a request cannot tell apart, a signature that does not match its
route, or a navigation entry that is not a static top-level page."""


class PageReadError(Exception):
"""A page could not be read. The message names the app and the page;
whatever the app's own code said stays in the process log, because it can
carry a query, a URL, or a credential."""

def __init__(self, app: str, page: str, detail: str) -> None:
super().__init__(f"app {app!r} page {page!r} could not be read: {detail}")
self.app = app
self.page = page


class PageContractError(PageReadError):
"""The page a function returned breaks the contract. Druks wrote this
message, so the dashboard shows it whole."""
31 changes: 25 additions & 6 deletions backend/tests/test_scaffolding.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path):
assert rendered
# The generated suite is what an author runs first; the scaffold is useless without it.
assert (target / "tests" / "test_app.py").is_file()
# Screens are Python. The scaffold writes no JavaScript and no dist/.
assert (package / "pages.py").is_file()
assert not (package / "dist").exists()
assert 'navigation = ["overview"]' in (package / "app.py").read_text()
for path in rendered:
assert "-tpl" not in path.name
assert "{{" not in path.read_text()
Expand All @@ -34,12 +38,14 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path):
lowered = text.lower()
assert "druks.storage" not in text
assert "taskiq" not in lowered
assert not path.name.endswith(".js")
assert "shellApi" not in text
assert "``config``" not in text
assert "subject_type" not in text
assert "Subject(" not in text

# The generated app.py must survive App.__init_subclass__ validation,
# and mounting must serve both the API routes and the shipped dist/ frontend.
# and mounting must serve its API routes and the pages it declares.
sys.path.insert(0, str(target))
try:
module = importlib.import_module("druks_night_watch.app")
Expand All @@ -52,7 +58,15 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path):
# module imports; the generated workflow resolves its identity from that.
register_workflow_package(night_watch.package, night_watch.name)

for role in ("models", "schemas", "contracts", "workflows", "routes", "subscribers"):
for role in (
"models",
"schemas",
"contracts",
"workflows",
"routes",
"pages",
"subscribers",
):
importlib.import_module(f"druks_night_watch.{role}")

# The workflow guidance must not teach a per-run app= argument —
Expand All @@ -67,10 +81,15 @@ def test_create_app_scaffolds_a_loadable_package(tmp_path):
api.dependency_overrides[current_account] = lambda: None
client = TestClient(api)
assert client.get("/api/night_watch/status").json() == {"app": "night_watch"}
entry = client.get("/app/night_watch/entry.js")
assert entry.status_code == 200
assert "shellApi" in entry.text
assert "night_watch" in entry.text
# The landing page is the scaffold's own, and it renders with no
# JavaScript anywhere in the package.
landing = client.get("/api/night_watch/pages")
assert landing.status_code == 200
assert landing.json()["title"] == "NightWatch"
assert landing.json()["blocks"][0]["block"] == "stack"
assert night_watch.frontend_dist() is None
assert [page.name for page in night_watch.pages()] == ["overview"]
assert [page.label for page in night_watch.navigation_pages()] == ["overview"]
finally:
sys.path.remove(str(target))
_workflow_packages.pop("druks_night_watch", None)
Expand Down
108 changes: 108 additions & 0 deletions backend/tests/test_ui_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import httpx
import pytest
from druks.apps.loader import load_app
from druks_field_notes.models import Note


async def failing_page(monkeypatch, project) -> httpx.AsyncClient:
"""A page router mounted after ``project`` replaced a page; the shared
server captured the real functions."""
from druks.accounts.dependencies import current_account
from druks.api.server import _page_read_error_handler
from druks.ui.exceptions import PageReadError
from druks_field_notes import pages
from fastapi import Depends, FastAPI

monkeypatch.setattr(pages.note, "function", project)
app = load_app("field_notes")
api = FastAPI()
api.add_exception_handler(PageReadError, _page_read_error_handler)
api.dependency_overrides[current_account] = lambda: None
api.include_router(
app._get_page_routes(), prefix="/api/field_notes", dependencies=[Depends(current_account)]
)
return api


async def test_a_page_that_raises_says_which_page_and_nothing_more(monkeypatch):
from druks.testing import asgi_client

async def raising(note_id: int):
raise RuntimeError("connection to postgres://secret@host failed")

api = await failing_page(monkeypatch, raising)
async with asgi_client(api) as client:
response = await client.get("/api/field_notes/pages/notes/1")

assert response.status_code == 500
detail = response.json()["detail"]
assert response.json()["error"] == "PAGE_FAILED"
assert (
detail
== "app 'field_notes' page 'note' could not be read: its own code raised RuntimeError"
)
# Whatever the app's own code said stays in the log.
assert "postgres" not in detail


async def test_a_page_that_answers_with_something_else_says_so(monkeypatch):
from druks.testing import asgi_client

async def not_a_page(note_id: int):
return {"title": "Note"}

api = await failing_page(monkeypatch, not_a_page)
async with asgi_client(api) as client:
response = await client.get("/api/field_notes/pages/notes/1")

assert response.status_code == 500
assert "not a Page" in response.json()["detail"]


async def test_a_page_naming_an_operation_the_app_lacks_says_which(monkeypatch):
from druks.testing import asgi_client
from druks.ui import Action, Page

async def bad_action(note_id: int):
return Page("Note", blocks=[Action(label="Go", operation="nowhere")])

api = await failing_page(monkeypatch, bad_action)
async with asgi_client(api) as client:
response = await client.get("/api/field_notes/pages/notes/1")

assert response.status_code == 500
assert "nowhere" in response.json()["detail"]


async def test_every_page_answers_for_the_proof_app(druks_client: httpx.AsyncClient, druks_db):
note = await Note.create(body="Fan noise on rack 3.")

for path in [
"",
"/recent",
"/notes/new",
f"/notes/{note.id}",
f"/notes/{note.id}/history",
]:
response = await druks_client.get(f"/api/field_notes/pages{path}")
assert response.status_code == 200, path
assert response.json()["title"], path


@pytest.mark.parametrize(
"path, says",
[("/nowhere", 404), ("/notes/not-a-number", 422)],
)
async def test_a_page_read_that_cannot_resolve_says_which(
druks_client: httpx.AsyncClient, path, says
):
assert (await druks_client.get(f"/api/field_notes/pages{path}")).status_code == says


def test_the_test_plugin_claims_every_installed_app():
from druks.apps.loader import _workflow_packages

# An app's own suite imports its workflows module, and a Workflow resolves
# its app at definition. Loading the plugin is what makes that work outside
# this repository.
assert _workflow_packages["druks_field_notes"] == "field_notes"
Loading