Skip to content

Commit 88558dd

Browse files
committed
The pluggable unit is an app, not an extension
Rename the Extension concept to App across the backend and frontend so one word runs through the whole surface: - `from druks.apps import App, AppSettings, Secret`; apps register under the `druks.apps` entry-point group and live in `<package>/app.py`. - Roster and settings routes become `/api/apps` and `/api/settings/apps`, with the `app` / `apps` / `appSettings` wire fields to match. - The events log column and the settings-override key prefix become `app`; one migration renames both in place, with a mirror downgrade. - The ASGI modules move to `druks.api.server` and `druks.mcp.server`. - `druks create app` scaffolds a new package from the app template. The `/api/<name>` and `/app/<name>` mounts, every app slug, and GitHub App settings are unchanged. Docs move to the new term separately.
1 parent d0a2a35 commit 88558dd

192 files changed

Lines changed: 1890 additions & 1888 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/on-pull-request-backend.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
python-version: "3.11"
6262
- run: uv sync --locked --dev
6363
- run: uv run ruff check backend
64-
# The proof extension is a standalone distribution that depends on druks;
64+
# The proof app is a standalone distribution that depends on druks;
6565
# it installs the way an author's would, so druks never depends on it.
6666
- run: uv pip install -e backend/tests/druks-field_notes
6767
# Hang defense is pytest-timeout (pyproject): 180s per test, thread

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ ENV USER=druks LOGNAME=druks
5353

5454
EXPOSE 8001
5555
ENTRYPOINT ["tini", "--"]
56-
CMD ["uvicorn", "druks.api.app:app", "--host", "127.0.0.1", "--port", "8001"]
56+
CMD ["uvicorn", "druks.api.server:app", "--host", "127.0.0.1", "--port", "8001"]

backend/druks/agents.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
from dbos import DBOS, StepOptions
1111
from pydantic import BaseModel, ConfigDict
1212

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

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

121121
# The effective settings, resolved through the override store: per-agent
@@ -142,11 +142,11 @@ async def __call__(self, **context: object) -> Any:
142142
workflow_id comes from the workflow context, not the caller; everything
143143
else (repo, …) is prompt context."""
144144
if not self.id:
145-
# Built loose — never assigned to an Extension, never given an explicit
145+
# Built loose — never assigned to an App, never given an explicit
146146
# id — so its settings, its registry entry and the call it would record
147147
# all key on nothing.
148148
raise WorkflowError(
149-
"an agent runs under its own id — declare it on an Extension, or "
149+
"an agent runs under its own id — declare it on an App, or "
150150
"pass id= for a standalone one"
151151
)
152152
workflow = current_workflow.get(None)

backend/druks/alembic_support.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

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

2020
def run_alembic_env(target_metadata=None) -> None:
2121
"""Run the platform Alembic env against the target metadata. Core and every
22-
installed extension share one ``env.py``; the runner selects the scope by setting
22+
installed app share one ``env.py``; the runner selects the scope by setting
2323
``target_metadata`` and ``version_table`` in ``config.attributes``. Metadata
2424
falls back to the full ``Base.metadata`` (core's own scope, e.g. core's raw
2525
autogenerate). ``include_object`` keeps autogenerate within the scope: a table
@@ -41,7 +41,7 @@ def include_object(obj, name, type_, reflected, compare_to):
4141
"render_item": _render_item,
4242
"include_object": include_object,
4343
# Each migration history tracks its head in its own version table, so an
44-
# installed extension's independent history never reads (and chokes on) core's
44+
# installed app's independent history never reads (and chokes on) core's
4545
# revision in the shared default ``alembic_version``.
4646
"version_table": config.attributes.get("version_table", "alembic_version"),
4747
}

backend/druks/api/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class RetryRunResponse(BaseResponse):
2828

2929

3030
class OpenWorkflowResponse(BaseResponse):
31-
extension: str
31+
app: str
3232
state: RunState
3333
run: str = Field(description="What get_gate, cancel_run, and retry_run take.")
3434
latest_agent_call: str | None = Field(
Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@
1818
from druks.api.exceptions import AgentApiError
1919
from druks.api.runs import router as runs_router
2020
from druks.api.subjects import router as subjects_router
21+
from druks.apps.loader import iter_apps, load
22+
from druks.apps.routes import router as apps_router
2123
from druks.browser.exceptions import BrowserApiError
2224
from druks.browser.routes import router as browser_sessions_router
2325
from druks.core.templates import render_page
2426
from druks.database import configure_session, create_engine_from_url, db_session, session_scope
2527
from druks.durable.engine import init_dbos, launch, shutdown
2628
from druks.durable.exceptions import AgentCallNotFound
2729
from druks.events.routes import router as events_router
28-
from druks.extensions.loader import iter_extensions, load
29-
from druks.extensions.routes import router as extensions_router
3030
from druks.harnesses.routes import router as harness_connection_router
3131
from druks.mcp.catalog import load_mcp_catalog
3232
from druks.mcp.gateway import exceptions as gate_errors
@@ -82,14 +82,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
8282
# reach here — they drive DBOS through their own fixtures.
8383
init_dbos()
8484
launch()
85-
# Each extension converges its own runtime state (e.g. schedules) here, after
86-
# DBOS is live. A failing hook is logged, not fatal — one extension can't wedge boot.
87-
for extension in iter_extensions():
85+
# Each app converges its own runtime state (e.g. schedules) here, after
86+
# DBOS is live. A failing hook is logged, not fatal — one app can't wedge boot.
87+
for registered_app in iter_apps():
8888
try:
89-
await extension.on_startup()
89+
await registered_app.on_startup()
9090
except Exception:
9191
logging.getLogger(__name__).exception(
92-
"extension %r on_startup failed", extension.name
92+
"app %r on_startup failed", registered_app.name
9393
)
9494

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

250250

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

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

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

321321

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

331331

332332
class SpaCacheControl:
333-
"""Cache policy for the served frontends (the SPA, an extension's dist/) —
333+
"""Cache policy for the served frontends (the SPA, an app's dist/) —
334334
it lives with their server, not the edge proxy. Vite fingerprints every
335335
asset filename (index-<hash>.js), so an asset URL's content never changes:
336336
cache it forever. index.html is the un-fingerprinted entry point that
@@ -350,7 +350,7 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
350350
await self.app(scope, receive, send)
351351
return
352352
path = scope["path"]
353-
# The SPA's /assets/* and an extension's /app/<name>/assets/* — never
353+
# The SPA's /assets/* and an app's /app/<name>/assets/* — never
354354
# /api/*, whose responses must not inherit frontend cache policy.
355355
fingerprinted = path.startswith("/assets/") or (
356356
path.startswith("/app/") and "/assets/" in path

backend/druks/api/subjects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def list_open_subjects() -> OpenSubjectsResponse:
3535
failure = failure.encode()[-_FAILURE_TAIL_BYTES:].decode(errors="ignore")
3636
workflows.append(
3737
OpenWorkflowResponse(
38-
extension=row.kind.partition(".")[0],
38+
app=row.kind.partition(".")[0],
3939
state=RunState(row.state),
4040
run=row.run_id,
4141
latest_agent_call=row.latest_call_id,

backend/druks/apps/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .base import App, AppSettings, Secret
2+
3+
__all__ = ["App", "AppSettings", "Secret"]

0 commit comments

Comments
 (0)