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
2 changes: 1 addition & 1 deletion .github/workflows/on-pull-request-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
python-version: "3.11"
- run: uv sync --locked --dev
- run: uv run ruff check backend
# The proof extension is a standalone distribution that depends on druks;
# The proof app is a standalone distribution that depends on druks;
# it installs the way an author's would, so druks never depends on it.
- run: uv pip install -e backend/tests/druks-field_notes
# Hang defense is pytest-timeout (pyproject): 180s per test, thread
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ ENV USER=druks LOGNAME=druks

EXPOSE 8001
ENTRYPOINT ["tini", "--"]
CMD ["uvicorn", "druks.api.app:app", "--host", "127.0.0.1", "--port", "8001"]
CMD ["uvicorn", "druks.api.server:app", "--host", "127.0.0.1", "--port", "8001"]
12 changes: 6 additions & 6 deletions backend/druks/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
from dbos import DBOS, StepOptions
from pydantic import BaseModel, ConfigDict

from druks.apps.registry import agents
from druks.database import db_session
from druks.durable.activity import set_run_phase
from druks.durable.engine import _step_engine, step_session
from druks.durable.exceptions import WorkflowError
from druks.durable.models import AgentCall, Artifact
from druks.extensions.registry import agents
from druks.harnesses.exceptions import HarnessError, Retry
from druks.harnesses.models import HarnessConnection
from druks.harnesses.registry import get_harness_for_model
Expand Down Expand Up @@ -102,10 +102,10 @@ class Agent:
include_plugins: bool = True
# ``id`` is the agent's durable key (settings, timeline, registry): the attribute
# name it's declared as, or an explicit ``id=`` for a standalone agent (a test, a
# one-off). ``extension`` is the owning Extension's name, read from the class in
# one-off). ``app`` is the owning App's name, read from the class in
# __set_name__ to group the settings UI — blank for a standalone agent (no owner).
id: str = field(default="", compare=False)
extension: str = field(init=False, compare=False, default="")
app: str = field(init=False, compare=False, default="")

def __post_init__(self) -> None:
if self.id: # an explicit id means a standalone agent — it registers itself now
Expand All @@ -115,7 +115,7 @@ def __set_name__(self, owner: type, attr: str) -> None:
if self.id: # explicit id: already registered in __post_init__
return
object.__setattr__(self, "id", attr)
object.__setattr__(self, "extension", owner.name)
object.__setattr__(self, "app", owner.name)
agents.register(self)

# The effective settings, resolved through the override store: per-agent
Expand All @@ -142,11 +142,11 @@ async def __call__(self, **context: object) -> Any:
workflow_id comes from the workflow context, not the caller; everything
else (repo, …) is prompt context."""
if not self.id:
# Built loose — never assigned to an Extension, never given an explicit
# Built loose — never assigned to an App, never given an explicit
# id — so its settings, its registry entry and the call it would record
# all key on nothing.
raise WorkflowError(
"an agent runs under its own id — declare it on an Extension, or "
"an agent runs under its own id — declare it on an App, or "
"pass id= for a standalone one"
)
workflow = current_workflow.get(None)
Expand Down
6 changes: 3 additions & 3 deletions backend/druks/alembic_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

def _render_item(type_, obj, autogen_context):
# Render app TypeDecorators as their plain DDL type so migrations never
# import extension code. _UtcDateTime only changes read-side tz coercion
# import app code. _UtcDateTime only changes read-side tz coercion
# (DDL is TIMESTAMPTZ); the encrypted columns store as LargeBinary (bytea).
if type_ == "type" and obj.__class__.__name__ == "_UtcDateTime":
return "sa.DateTime(timezone=True)"
Expand All @@ -19,7 +19,7 @@ def _render_item(type_, obj, autogen_context):

def run_alembic_env(target_metadata=None) -> None:
"""Run the platform Alembic env against the target metadata. Core and every
installed extension share one ``env.py``; the runner selects the scope by setting
installed app share one ``env.py``; the runner selects the scope by setting
``target_metadata`` and ``version_table`` in ``config.attributes``. Metadata
falls back to the full ``Base.metadata`` (core's own scope, e.g. core's raw
autogenerate). ``include_object`` keeps autogenerate within the scope: a table
Expand All @@ -41,7 +41,7 @@ def include_object(obj, name, type_, reflected, compare_to):
"render_item": _render_item,
"include_object": include_object,
# Each migration history tracks its head in its own version table, so an
# installed extension's independent history never reads (and chokes on) core's
# installed app's independent history never reads (and chokes on) core's
# revision in the shared default ``alembic_version``.
"version_table": config.attributes.get("version_table", "alembic_version"),
}
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class RetryRunResponse(BaseResponse):


class OpenWorkflowResponse(BaseResponse):
extension: str
app: str
state: RunState
run: str = Field(description="What get_gate, cancel_run, and retry_run take.")
latest_agent_call: str | None = Field(
Expand Down
34 changes: 17 additions & 17 deletions backend/druks/api/app.py → backend/druks/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@
from druks.api.exceptions import AgentApiError
from druks.api.runs import router as runs_router
from druks.api.subjects import router as subjects_router
from druks.apps.loader import iter_apps, load
from druks.apps.routes import router as apps_router
from druks.browser.exceptions import BrowserApiError
from druks.browser.routes import router as browser_sessions_router
from druks.core.templates import render_page
from druks.database import configure_session, create_engine_from_url, db_session, session_scope
from druks.durable.engine import init_dbos, launch, shutdown
from druks.durable.exceptions import AgentCallNotFound
from druks.events.routes import router as events_router
from druks.extensions.loader import iter_extensions, load
from druks.extensions.routes import router as extensions_router
from druks.harnesses.routes import router as harness_connection_router
from druks.mcp.catalog import load_mcp_catalog
from druks.mcp.gateway import exceptions as gate_errors
Expand Down Expand Up @@ -82,14 +82,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# reach here — they drive DBOS through their own fixtures.
init_dbos()
launch()
# Each extension converges its own runtime state (e.g. schedules) here, after
# DBOS is live. A failing hook is logged, not fatal — one extension can't wedge boot.
for extension in iter_extensions():
# Each app converges its own runtime state (e.g. schedules) here, after
# DBOS is live. A failing hook is logged, not fatal — one app can't wedge boot.
for registered_app in iter_apps():
try:
await extension.on_startup()
await registered_app.on_startup()
except Exception:
logging.getLogger(__name__).exception(
"extension %r on_startup failed", extension.name
"app %r on_startup failed", registered_app.name
)

try:
Expand All @@ -108,7 +108,7 @@ async def _release_db_session() -> AsyncIterator[None]:
without committing, so this is the commit boundary. FastAPI runs this
yield-dependency's teardown in the *same* asyncio task as the endpoint
(the scoped_session is keyed by that task), so it acts on exactly the
session this request opened. Frontend responses (the SPA, an extension's
session this request opened. Frontend responses (the SPA, an app's
dist/) run app dependencies too, so only touch the registry when the
request actually opened a session — never open one just to commit nothing.
"""
Expand Down Expand Up @@ -248,8 +248,8 @@ async def _unhandled_exception_handler(
)


# Platform-core routers, mounted by hand at their own prefixes. Extension routers
# (core, ship, usage, …) are discovered and mounted under /api/<extension> by load().
# Platform-core routers, mounted by hand at their own prefixes. App routers
# (core, ship, usage, …) are discovered and mounted under /api/<app> by load().
# /api sits behind the identity gate except the identity/connection surface and
# the health probe; /_external routes carry their own authentication. The
# boundary test pins the split. The auth and harness-connection routers mount
Expand All @@ -265,7 +265,7 @@ async def _unhandled_exception_handler(
app.include_router(harness_connection_router)
app.include_router(browser_sessions_router)
app.include_router(settings_router, dependencies=_identity_gate)
app.include_router(extensions_router, dependencies=_identity_gate)
app.include_router(apps_router, dependencies=_identity_gate)
app.include_router(service_identities_router, dependencies=_identity_gate)
app.include_router(oauth_router, dependencies=_identity_gate)
app.include_router(skills_router, dependencies=_identity_gate)
Expand All @@ -279,8 +279,8 @@ async def _unhandled_exception_handler(
load(app)

# Tools derive from every route tagged "agent", so the endpoint composes
# after load() — an extension's tagged routes join tools/list too.
from druks.mcp.app import create_mcp_app # noqa: E402
# after load() — an app's tagged routes join tools/list too.
from druks.mcp.server import create_mcp_app # noqa: E402

# A bare Route at exactly /mcp (a Mount would 307 the no-slash path); PATs
# authenticate it, so it sits outside the identity gate.
Expand Down Expand Up @@ -320,17 +320,17 @@ async def mcp_not_found(path: str) -> None:


def serve_spa(app: FastAPI, dist: Path = _SPA_DIST) -> None:
"""Serve the platform's own dashboard the way an extension's ``dist/`` is
"""Serve the platform's own dashboard the way an app's ``dist/`` is
served — FastAPI's low-priority frontend routes, so every API path (and every
extension frontend, registered earlier) wins, and unknown paths fall back to
app frontend, registered earlier) wins, and unknown paths fall back to
index.html for client-side routing. A bare pip install ships no SPA build;
then the API serves JSON only."""
if (dist / "index.html").is_file():
app.frontend("/", directory=dist)


class SpaCacheControl:
"""Cache policy for the served frontends (the SPA, an extension's dist/) —
"""Cache policy for the served frontends (the SPA, an app's dist/) —
it lives with their server, not the edge proxy. Vite fingerprints every
asset filename (index-<hash>.js), so an asset URL's content never changes:
cache it forever. index.html is the un-fingerprinted entry point that
Expand All @@ -350,7 +350,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
await self.app(scope, receive, send)
return
path = scope["path"]
# The SPA's /assets/* and an extension's /app/<name>/assets/* — never
# The SPA's /assets/* and an app's /app/<name>/assets/* — never
# /api/*, whose responses must not inherit frontend cache policy.
fingerprinted = path.startswith("/assets/") or (
path.startswith("/app/") and "/assets/" in path
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/api/subjects.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def list_open_subjects() -> OpenSubjectsResponse:
failure = failure.encode()[-_FAILURE_TAIL_BYTES:].decode(errors="ignore")
workflows.append(
OpenWorkflowResponse(
extension=row.kind.partition(".")[0],
app=row.kind.partition(".")[0],
state=RunState(row.state),
run=row.run_id,
latest_agent_call=row.latest_call_id,
Expand Down
3 changes: 3 additions & 0 deletions backend/druks/apps/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .base import App, AppSettings, Secret

__all__ = ["App", "AppSettings", "Secret"]
Loading