From 88558ddaee370eadd4dbdaff263aa0b740507409 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 23 Aug 2026 08:34:19 +0200 Subject: [PATCH] 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 `/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/` and `/app/` mounts, every app slug, and GitHub App settings are unchanged. Docs move to the new term separately. --- .github/workflows/on-pull-request-backend.yml | 2 +- Dockerfile | 2 +- backend/druks/agents.py | 12 +- backend/druks/alembic_support.py | 6 +- backend/druks/api/schemas.py | 2 +- backend/druks/api/{app.py => server.py} | 34 +-- backend/druks/api/subjects.py | 2 +- backend/druks/apps/__init__.py | 3 + backend/druks/{extensions => apps}/base.py | 152 ++++++----- backend/druks/{extensions => apps}/config.py | 12 +- backend/druks/apps/exceptions.py | 45 ++++ backend/druks/{extensions => apps}/fetcher.py | 0 backend/druks/apps/loader.py | 250 +++++++++++++++++ .../druks/{extensions => apps}/registry.py | 0 backend/druks/apps/routes.py | 22 ++ backend/druks/{extensions => apps}/schemas.py | 4 +- .../druks/{extensions => apps}/settings.py | 0 backend/druks/browser/exceptions.py | 4 +- backend/druks/browser/routes.py | 2 +- backend/druks/browser/sessions.py | 12 +- backend/druks/cli.py | 22 +- .../contrib/review/{extension.py => app.py} | 10 +- backend/druks/contrib/review/github.py | 2 +- backend/druks/contrib/review/workflows.py | 2 +- .../contrib/ship/{extension.py => app.py} | 8 +- backend/druks/contrib/ship/models.py | 14 +- backend/druks/contrib/ship/policy.py | 4 +- backend/druks/contrib/ship/routes.py | 2 +- backend/druks/contrib/ship/subscribers.py | 2 +- backend/druks/contrib/ship/workflows.py | 2 +- backend/druks/core/{extension.py => app.py} | 8 +- backend/druks/core/routes.py | 2 +- backend/druks/database.py | 54 ++-- backend/druks/db.py | 2 +- backend/druks/doctor.py | 46 ++-- backend/druks/durable/datastructures.py | 8 +- backend/druks/durable/models.py | 4 +- backend/druks/durable/reads.py | 4 +- backend/druks/durable/schemas.py | 8 +- backend/druks/events/builder.py | 12 +- backend/druks/events/feed.py | 4 +- backend/druks/events/models.py | 8 +- backend/druks/events/routes.py | 8 +- backend/druks/extensions/__init__.py | 3 - backend/druks/extensions/exceptions.py | 45 ---- backend/druks/extensions/loader.py | 253 ------------------ backend/druks/extensions/routes.py | 22 -- backend/druks/harnesses/codex.py | 2 +- backend/druks/mcp/catalog.py | 2 +- backend/druks/mcp/models.py | 2 +- backend/druks/mcp/routes.py | 2 +- backend/druks/mcp/{app.py => server.py} | 26 +- backend/druks/models.py | 8 +- backend/druks/prompts/resolver.py | 40 +-- backend/druks/sandbox/datastructures.py | 4 +- backend/druks/scaffolding/__init__.py | 18 +- .../AGENTS.md-tpl | 14 +- .../package/__init__.py-tpl | 0 .../package/app.py-tpl} | 8 +- .../package/contracts.py-tpl | 0 .../package/dist/entry.js-tpl | 0 .../package/migrations/versions/.gitkeep | 0 .../package/models.py-tpl | 6 +- .../package/routes.py-tpl | 4 +- .../package/schemas.py-tpl | 2 +- .../package/subscribers.py-tpl | 0 .../package/workflows.py-tpl | 0 .../pyproject.toml-tpl | 8 +- .../tests/test_app.py-tpl} | 0 backend/druks/services/base.py | 22 +- backend/druks/services/routes.py | 2 +- backend/druks/signals.py | 2 +- backend/druks/testing.py | 22 +- backend/druks/usage/{extension.py => app.py} | 4 +- backend/druks/user_settings/models.py | 10 +- backend/druks/user_settings/reads.py | 32 +-- backend/druks/user_settings/routes.py | 46 ++-- backend/druks/user_settings/schemas.py | 20 +- backend/druks/webhooks/base.py | 2 +- backend/druks/webhooks/router.py | 2 +- backend/druks/workflows.py | 50 ++-- .../f1d8c6a2b947_apps_are_canonical.py | 33 +++ backend/tests/conftest.py | 32 +-- .../{extension.py => app.py} | 14 +- .../versions/field_notes_0001_notes.py | 2 +- .../druks_field_notes/models.py | 4 +- .../druks_field_notes/subscribers.py | 4 +- .../druks_field_notes/workflows.py | 4 +- .../tests/druks-field_notes/pyproject.toml | 8 +- .../tests/{test_extension.py => test_app.py} | 4 +- .../druks-field_notes/tests/test_workflows.py | 2 +- backend/tests/ship/test_api_work_items.py | 8 +- ...extension_config.py => test_app_config.py} | 34 +-- backend/tests/ship/test_build_dispatch.py | 2 +- backend/tests/ship/test_build_plan_phase.py | 2 +- backend/tests/ship/test_build_workspace.py | 6 +- backend/tests/ship/test_profiling.py | 2 +- backend/tests/ship/test_ticketing.py | 2 +- .../tests/ship/test_webhooks_pull_request.py | 4 +- backend/tests/test_agent_routes.py | 12 +- backend/tests/test_agents.py | 2 +- backend/tests/test_api_settings.py | 170 ++++++------ backend/tests/test_api_usage.py | 16 +- ...or_checks.py => test_app_doctor_checks.py} | 110 ++++---- ...less_load.py => test_app_headless_load.py} | 240 ++++++++--------- ...extension_loader.py => test_app_loader.py} | 18 +- ...n_migrations.py => test_app_migrations.py} | 22 +- ...extension_roster.py => test_app_roster.py} | 4 +- .../{test_extensions.py => test_apps.py} | 122 ++++----- backend/tests/test_auth_boundary.py | 6 +- backend/tests/test_auth_pats.py | 8 +- backend/tests/test_author_surface.py | 4 +- backend/tests/test_browser_borrow.py | 4 +- backend/tests/test_doctor.py | 2 +- .../tests/test_doctor_capability_modules.py | 6 +- backend/tests/test_durable_sdk.py | 4 +- backend/tests/test_events_feed.py | 12 +- backend/tests/test_generic_subjects.py | 16 +- backend/tests/test_github.py | 2 +- backend/tests/test_mcp_endpoint.py | 26 +- backend/tests/test_mcp_oauth.py | 2 +- backend/tests/test_mcp_servers.py | 2 +- backend/tests/test_notifications_durable.py | 2 +- backend/tests/test_proof_app.py | 33 +++ ...n_install.py => test_proof_app_install.py} | 18 +- ...gration.py => test_proof_app_migration.py} | 4 +- backend/tests/test_proof_extension.py | 33 --- backend/tests/test_review.py | 8 +- backend/tests/test_sandbox_runner.py | 2 +- backend/tests/test_scaffolding.py | 54 ++-- backend/tests/test_services.py | 10 +- backend/tests/test_settings_overrides.py | 12 +- backend/tests/test_signals.py | 2 +- backend/tests/test_spa_serving.py | 2 +- backend/tests/test_user_settings.py | 14 +- backend/tests/test_webhooks_framework.py | 2 +- backend/tests/test_workflow_identity.py | 44 +-- deploy/caddy/Caddyfile | 4 +- deploy/compose.yaml | 2 +- frontend/src/App.tsx | 126 ++++----- frontend/src/api/client.ts | 54 ++-- frontend/src/api/types.ts | 44 +-- .../{extensions => apps}/InstalledAppHost.tsx | 14 +- frontend/src/apps/index.ts | 6 + .../src/{extensions => apps}/installed.tsx | 22 +- frontend/src/apps/registry.tsx | 83 ++++++ .../review/ReviewsPage.tsx | 2 +- .../src/{extensions => apps}/review/api.ts | 0 .../{extensions => apps}/review/reviewLine.ts | 2 +- .../src/{extensions => apps}/review/ui.tsx | 4 +- .../ship/AgentCallPage.tsx | 2 +- .../{extensions => apps}/ship/HistoryPage.tsx | 0 .../{extensions => apps}/ship/NotFound.tsx | 0 .../{extensions => apps}/ship/StatusTag.tsx | 0 .../ship/WorkItemPage.tsx | 2 +- .../ship/WorkItemsPage.tsx | 0 frontend/src/{extensions => apps}/ship/api.ts | 2 +- .../ship/projects/ProjectsPage.test.tsx | 0 .../ship/projects/ProjectsPage.tsx | 2 +- .../{extensions => apps}/ship/projects/api.ts | 0 .../ship/projects/profiling.test.ts | 0 .../ship/projects/profiling.ts | 0 .../ship/projects/types.ts | 0 .../src/{extensions => apps}/ship/slug.ts | 0 .../ship/statusLine.test.ts | 0 .../{extensions => apps}/ship/statusLine.ts | 2 +- frontend/src/{extensions => apps}/ship/ui.tsx | 10 +- ...{ExtensionDropdown.tsx => AppDropdown.tsx} | 56 ++-- .../{ExtensionGlyph.tsx => AppGlyph.tsx} | 10 +- frontend/src/components/BackToApp.tsx | 33 +++ frontend/src/components/BackToExtension.tsx | 33 --- .../src/components/BrowserSessionsPane.tsx | 4 +- frontend/src/components/RunTranscript.tsx | 2 +- .../src/components/SettingsModal.test.tsx | 22 +- frontend/src/components/SettingsModal.tsx | 196 +++++++------- frontend/src/extensions/index.ts | 6 - frontend/src/extensions/registry.tsx | 83 ------ frontend/src/lib/appColors.ts | 9 + frontend/src/lib/extensionColors.ts | 9 - frontend/src/lib/feed.test.ts | 16 +- frontend/src/lib/feed.ts | 16 +- frontend/src/lib/summary.ts | 2 +- frontend/src/lib/useScreenWakeLock.ts | 2 +- ...{ExtensionHomePage.tsx => AppHomePage.tsx} | 16 +- frontend/src/pages/EventsPage.tsx | 36 +-- frontend/src/pages/SubjectPage.tsx | 24 +- frontend/src/pages/UsagePage.tsx | 6 +- frontend/src/styles.css | 84 +++--- frontend/tsconfig.app.json | 2 +- frontend/vite.config.ts | 4 +- pyproject.toml | 20 +- scripts/import_browser_session.py | 2 +- 192 files changed, 1890 insertions(+), 1888 deletions(-) rename backend/druks/api/{app.py => server.py} (94%) create mode 100644 backend/druks/apps/__init__.py rename backend/druks/{extensions => apps}/base.py (78%) rename backend/druks/{extensions => apps}/config.py (54%) create mode 100644 backend/druks/apps/exceptions.py rename backend/druks/{extensions => apps}/fetcher.py (100%) create mode 100644 backend/druks/apps/loader.py rename backend/druks/{extensions => apps}/registry.py (100%) create mode 100644 backend/druks/apps/routes.py rename backend/druks/{extensions => apps}/schemas.py (64%) rename backend/druks/{extensions => apps}/settings.py (100%) rename backend/druks/contrib/review/{extension.py => app.py} (88%) rename backend/druks/contrib/ship/{extension.py => app.py} (97%) rename backend/druks/core/{extension.py => app.py} (52%) delete mode 100644 backend/druks/extensions/__init__.py delete mode 100644 backend/druks/extensions/exceptions.py delete mode 100644 backend/druks/extensions/loader.py delete mode 100644 backend/druks/extensions/routes.py rename backend/druks/mcp/{app.py => server.py} (89%) rename backend/druks/scaffolding/{extension_template => app_template}/AGENTS.md-tpl (72%) rename backend/druks/scaffolding/{extension_template => app_template}/package/__init__.py-tpl (100%) rename backend/druks/scaffolding/{extension_template/package/extension.py-tpl => app_template/package/app.py-tpl} (62%) rename backend/druks/scaffolding/{extension_template => app_template}/package/contracts.py-tpl (100%) rename backend/druks/scaffolding/{extension_template => app_template}/package/dist/entry.js-tpl (100%) rename backend/druks/scaffolding/{extension_template => app_template}/package/migrations/versions/.gitkeep (100%) rename backend/druks/scaffolding/{extension_template => app_template}/package/models.py-tpl (72%) rename backend/druks/scaffolding/{extension_template => app_template}/package/routes.py-tpl (70%) rename backend/druks/scaffolding/{extension_template => app_template}/package/schemas.py-tpl (66%) rename backend/druks/scaffolding/{extension_template => app_template}/package/subscribers.py-tpl (100%) rename backend/druks/scaffolding/{extension_template => app_template}/package/workflows.py-tpl (100%) rename backend/druks/scaffolding/{extension_template => app_template}/pyproject.toml-tpl (65%) rename backend/druks/scaffolding/{extension_template/tests/test_extension.py-tpl => app_template/tests/test_app.py-tpl} (100%) rename backend/druks/usage/{extension.py => app.py} (68%) create mode 100644 backend/migrations/versions/f1d8c6a2b947_apps_are_canonical.py rename backend/tests/druks-field_notes/druks_field_notes/{extension.py => app.py} (89%) rename backend/tests/druks-field_notes/tests/{test_extension.py => test_app.py} (93%) rename backend/tests/ship/{test_extension_config.py => test_app_config.py} (88%) rename backend/tests/{test_extension_doctor_checks.py => test_app_doctor_checks.py} (65%) rename backend/tests/{test_extension_appless_load.py => test_app_headless_load.py} (55%) rename backend/tests/{test_extension_loader.py => test_app_loader.py} (52%) rename backend/tests/{test_extension_migrations.py => test_app_migrations.py} (83%) rename backend/tests/{test_extension_roster.py => test_app_roster.py} (87%) rename backend/tests/{test_extensions.py => test_apps.py} (64%) create mode 100644 backend/tests/test_proof_app.py rename backend/tests/{test_proof_extension_install.py => test_proof_app_install.py} (60%) rename backend/tests/{test_proof_extension_migration.py => test_proof_app_migration.py} (92%) delete mode 100644 backend/tests/test_proof_extension.py rename frontend/src/{extensions => apps}/InstalledAppHost.tsx (90%) create mode 100644 frontend/src/apps/index.ts rename frontend/src/{extensions => apps}/installed.tsx (72%) create mode 100644 frontend/src/apps/registry.tsx rename frontend/src/{extensions => apps}/review/ReviewsPage.tsx (98%) rename frontend/src/{extensions => apps}/review/api.ts (100%) rename frontend/src/{extensions => apps}/review/reviewLine.ts (91%) rename frontend/src/{extensions => apps}/review/ui.tsx (71%) rename frontend/src/{extensions => apps}/ship/AgentCallPage.tsx (99%) rename frontend/src/{extensions => apps}/ship/HistoryPage.tsx (100%) rename frontend/src/{extensions => apps}/ship/NotFound.tsx (100%) rename frontend/src/{extensions => apps}/ship/StatusTag.tsx (100%) rename frontend/src/{extensions => apps}/ship/WorkItemPage.tsx (99%) rename frontend/src/{extensions => apps}/ship/WorkItemsPage.tsx (100%) rename frontend/src/{extensions => apps}/ship/api.ts (97%) rename frontend/src/{extensions => apps}/ship/projects/ProjectsPage.test.tsx (100%) rename frontend/src/{extensions => apps}/ship/projects/ProjectsPage.tsx (99%) rename frontend/src/{extensions => apps}/ship/projects/api.ts (100%) rename frontend/src/{extensions => apps}/ship/projects/profiling.test.ts (100%) rename frontend/src/{extensions => apps}/ship/projects/profiling.ts (100%) rename frontend/src/{extensions => apps}/ship/projects/types.ts (100%) rename frontend/src/{extensions => apps}/ship/slug.ts (100%) rename frontend/src/{extensions => apps}/ship/statusLine.test.ts (100%) rename frontend/src/{extensions => apps}/ship/statusLine.ts (97%) rename frontend/src/{extensions => apps}/ship/ui.tsx (81%) rename frontend/src/components/{ExtensionDropdown.tsx => AppDropdown.tsx} (58%) rename frontend/src/components/{ExtensionGlyph.tsx => AppGlyph.tsx} (79%) create mode 100644 frontend/src/components/BackToApp.tsx delete mode 100644 frontend/src/components/BackToExtension.tsx delete mode 100644 frontend/src/extensions/index.ts delete mode 100644 frontend/src/extensions/registry.tsx create mode 100644 frontend/src/lib/appColors.ts delete mode 100644 frontend/src/lib/extensionColors.ts rename frontend/src/pages/{ExtensionHomePage.tsx => AppHomePage.tsx} (80%) diff --git a/.github/workflows/on-pull-request-backend.yml b/.github/workflows/on-pull-request-backend.yml index 8da078fa..19134e52 100644 --- a/.github/workflows/on-pull-request-backend.yml +++ b/.github/workflows/on-pull-request-backend.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 4e2ab836..b3afb75d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/backend/druks/agents.py b/backend/druks/agents.py index 3883a220..abb56e6f 100644 --- a/backend/druks/agents.py +++ b/backend/druks/agents.py @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/backend/druks/alembic_support.py b/backend/druks/alembic_support.py index 1667a042..2ca9d7f8 100644 --- a/backend/druks/alembic_support.py +++ b/backend/druks/alembic_support.py @@ -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)" @@ -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 @@ -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"), } diff --git a/backend/druks/api/schemas.py b/backend/druks/api/schemas.py index ae9180ee..a184198f 100644 --- a/backend/druks/api/schemas.py +++ b/backend/druks/api/schemas.py @@ -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( diff --git a/backend/druks/api/app.py b/backend/druks/api/server.py similarity index 94% rename from backend/druks/api/app.py rename to backend/druks/api/server.py index b944ca16..1d10629d 100644 --- a/backend/druks/api/app.py +++ b/backend/druks/api/server.py @@ -18,6 +18,8 @@ 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 @@ -25,8 +27,6 @@ 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 @@ -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: @@ -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. """ @@ -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/ by load(). +# Platform-core routers, mounted by hand at their own prefixes. App routers +# (core, ship, usage, …) are discovered and mounted under /api/ 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 @@ -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) @@ -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. @@ -320,9 +320,9 @@ 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(): @@ -330,7 +330,7 @@ def serve_spa(app: FastAPI, dist: Path = _SPA_DIST) -> None: 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-.js), so an asset URL's content never changes: 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: await self.app(scope, receive, send) return path = scope["path"] - # The SPA's /assets/* and an extension's /app//assets/* — never + # The SPA's /assets/* and an app's /app//assets/* — never # /api/*, whose responses must not inherit frontend cache policy. fingerprinted = path.startswith("/assets/") or ( path.startswith("/app/") and "/assets/" in path diff --git a/backend/druks/api/subjects.py b/backend/druks/api/subjects.py index 1037c951..b90bb2ea 100644 --- a/backend/druks/api/subjects.py +++ b/backend/druks/api/subjects.py @@ -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, diff --git a/backend/druks/apps/__init__.py b/backend/druks/apps/__init__.py new file mode 100644 index 00000000..beba8d65 --- /dev/null +++ b/backend/druks/apps/__init__.py @@ -0,0 +1,3 @@ +from .base import App, AppSettings, Secret + +__all__ = ["App", "AppSettings", "Secret"] diff --git a/backend/druks/extensions/base.py b/backend/druks/apps/base.py similarity index 78% rename from backend/druks/extensions/base.py rename to backend/druks/apps/base.py index 24b6f1b3..59db624f 100644 --- a/backend/druks/extensions/base.py +++ b/backend/druks/apps/base.py @@ -11,7 +11,7 @@ from druks.models import StoredSubject from druks.user_settings.models import SettingsOverride -from .exceptions import ExtensionSubjectContractError, SettingsDeclarationError +from .exceptions import AppSubjectContractError, SettingsDeclarationError from .registry import agents as agent_registry from .registry import autodiscover from .registry import workflows as workflow_registry @@ -31,14 +31,14 @@ from druks.durable.schemas import SubjectActivity from druks.workflows import Workflow - # A check the extension owns returns a verdict on one of its own preconditions + # A check the app owns returns a verdict on one of its own preconditions # using the same ``CheckResult`` shape as a core check. Check = Callable[[], CheckResult] -# An extension name keys the ``/api/`` namespace, the ``alembic_version_`` -# table, the ``_`` table prefix, and ``extension::`` settings — so it must +# An app name keys the ``/api/`` namespace, the ``alembic_version_`` +# table, the ``_`` table prefix, and ``app::`` settings — so it must # be a lowercase SQL/URL-safe identifier. Public: the scaffolder validates against the -# same rule so ``druks create extension`` can't emit a package that fails this check. +# same rule so ``druks create app`` can't emit a package that fails this check. NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") @@ -47,7 +47,7 @@ Secret = Annotated[SecretStr, Field(default=SecretStr(""))] -class ExtensionSettings(BaseModel): +class AppSettings(BaseModel): def clean(self) -> dict[str, str]: """Problems with the resolved settings, keyed by the field the operator must fix. Advisory at rest, blocking on save: the settings form rejects @@ -55,56 +55,56 @@ def clean(self) -> dict[str, str]: return {} -class Extension: +class App: """A pluggable application. Subclass it, set ``name``, and register the - subclass under the ``druks.extensions`` entry-point group. At boot the loader + subclass under the ``druks.apps`` entry-point group. At boot the loader calls ``discover``, which imports the package's conventionally-named modules — - that import is where the extension's webhooks, workflows, agents, and + that import is where the app's webhooks, workflows, agents, and subscribers self-register. - Used as a class, never instantiated: an extension is a stateless install + Used as a class, never instantiated: an app is a stateless install singleton, so an instance would only be ceremony. """ name: ClassVar[str] - # Tables this extension owns are prefixed ``_`` — the SQLAlchemy stand-in for a - # Django ``app_label``, derived from ``name``. The platform scopes the extension's + # Tables this app owns are prefixed ``_`` — the SQLAlchemy stand-in for a + # Django ``app_label``, derived from ``name``. The platform scopes the app's # autogenerate to this prefix and tracks its history in ``alembic_version_``. table_prefix: ClassVar[str] - # Whether this extension's tables must carry the ``_`` prefix. True for a - # normally-shipped extension, so its schema can't collide with core or another - # extension. An extension whose tables instead live in the platform's own migration + # Whether this app's tables must carry the ``_`` prefix. True for a + # normally-shipped app, so its schema can't collide with core or another + # app. An app whose tables instead live in the platform's own migration # history — bundled and predating the prefix convention — sets this False to opt # those tables out of the boot-time check. prefix_tables: ClassVar[bool] = True # The rail glyph, named from the Lucide set the frontend bundles (e.g. - # "telescope", "hammer" — see the UI's APP_ICONS for the available names). A - # extension just names one, so a separately-shipped package gets a glyph without + # "telescope", "hammer" — see the UI's APP_ICONS for the available names). An + # app just names one, so a separately-shipped package gets a glyph without # touching the frontend; unknown names fall back to the default. icon: ClassVar[str] = "box" - # One-line blurb shown in the settings pane when the extension is selected. + # One-line blurb shown in the settings pane when the app is selected. description: ClassVar[str] = "" - # The extension's appbar subnav tabs, as (url, name) pairs. The switcher + # The app's appbar subnav tabs, as (url, name) pairs. The switcher # label itself is derived from ``name`` (underscores become spaces), so an - # extension only declares destinations, never a title. + # app only declares destinations, never a title. navigation: ClassVar[list[tuple[str, str]]] = [] - # The extension's top-level package, walked by ``discover``. Defaults to the - # package the subclass is defined in — the ``/extension.py`` convention - # means that's always the extension's root — so it's only set explicitly when the + # The app's top-level package, walked by ``discover``. Defaults to the + # package the subclass is defined in — the ``/app.py`` convention + # means that's always the app's root — so it's only set explicitly when the # class lives somewhere other than its package root. package: ClassVar[str] - # ``builtin`` extensions carry platform-core settings rather than a user-facing - # extension — the settings UI folds their agents into the Druks tab instead of + # ``builtin`` apps carry platform-core settings rather than a user-facing + # app — the settings UI folds their agents into the Druks tab instead of # giving them their own. builtin: ClassVar[bool] = False - # The extension's declared ``Settings`` inner class, if any — operator knobs that - # belong to the extension itself rather than one of its workflows. Mirrors a + # The app's declared ``Settings`` inner class, if any — operator knobs that + # belong to the app itself rather than one of its workflows. Mirrors a # workflow's ``Settings``. - settings_model: ClassVar[type[ExtensionSettings] | None] = None - # The checks this extension contributes to ``druks doctor`` — one per precondition + settings_model: ClassVar[type[AppSettings] | None] = None + # The checks this app contributes to ``druks doctor`` — one per precondition # beyond resolved-settings coherence (for example, whether its provider is reachable). # ``druks doctor`` runs each through the same ``CheckResult`` report as its core - # checks, isolating a raising one under this extension's name so it can't hide a + # checks, isolating a raising one under this app's name so it can't hide a # core failure. Default none; declare a list to add them. checks: "ClassVar[list[Check]]" = [] @@ -115,13 +115,13 @@ def __init_subclass__(cls, **kwargs: Any) -> None: raise TypeError(f"{cls.__name__} must set a `name`") if not NAME_RE.match(name): raise TypeError( - f"extension name {name!r} must match {NAME_RE.pattern!r} — it keys the " + f"app name {name!r} must match {NAME_RE.pattern!r} — it keys the " "/api/ namespace, the version table, and settings keys" ) if "subject_type" in cls.__dict__: raise TypeError( f"{cls.__name__} declares subject_type — a workflow declares what its runs " - "are about (``subject = YourSubject``), and an extension's subjects follow " + "are about (``subject = YourSubject``), and an app's subjects follow " "from its workflows" ) cls.table_prefix = f"{name}_" @@ -129,22 +129,20 @@ def __init_subclass__(cls, **kwargs: Any) -> None: cls.package = cls.__module__.rpartition(".")[0] declared = cls.__dict__.get("Settings") if declared is not None: - if not isinstance(declared, type) or not issubclass(declared, ExtensionSettings): - raise SettingsDeclarationError( - f"{cls.__name__}.Settings must subclass ExtensionSettings" - ) + if not isinstance(declared, type) or not issubclass(declared, AppSettings): + raise SettingsDeclarationError(f"{cls.__name__}.Settings must subclass AppSettings") validate_settings_declaration(declared) cls.settings_model = declared @classmethod - def settings(cls) -> ExtensionSettings: - """The extension's settings, resolved through the override store keyed by extension - name. Raises if the extension declares no ``Settings``.""" + def settings(cls) -> AppSettings: + """The app's settings, resolved through the override store keyed by app + name. Raises if the app declares no ``Settings``.""" model = cls.settings_model if not model: - raise TypeError(f"extension {cls.name!r} declares no Settings") + raise TypeError(f"app {cls.name!r} declares no Settings") values = { - name: SettingsOverride.extension_setting( + name: SettingsOverride.app_setting( cls.name, name, field.default, @@ -164,7 +162,7 @@ def override_setting(cls, field: str, value: Any) -> None: if value is not None: value = coerce_setting_value(model, field, value) validate_setting_override(model, cls.settings().model_dump(), field, value) - SettingsOverride.set_extension_setting( + SettingsOverride.set_app_setting( cls.name, field, value, @@ -173,13 +171,13 @@ def override_setting(cls, field: str, value: Any) -> None: @classmethod def agents(cls) -> "list[Agent]": - """The agents declared on this extension class — their ``extension`` field is + """The agents declared on this app class — their ``app`` field is stamped in ``__set_name__``. Registry order is id-sorted.""" - return [agent for agent in agent_registry.all() if agent.extension == cls.name] + return [agent for agent in agent_registry.all() if agent.app == cls.name] @classmethod def workflows(cls) -> "list[type[Workflow]]": - """The workflows living in this extension's package.""" + """The workflows living in this app's package.""" prefix = cls.package + "." return [ workflow @@ -189,7 +187,7 @@ def workflows(cls) -> "list[type[Workflow]]": @classmethod def subjects(cls) -> "list[type[Subject] | type[StoredSubject]]": - """The subjects this extension's workflows declare, ordered by subject type. + """The subjects this app's workflows declare, ordered by subject type. Each must implement ``list_summaries()``. The check compares method identity and does not call the method.""" from druks.durable.datastructures import Subject @@ -198,13 +196,13 @@ def subjects(cls) -> "list[type[Subject] | type[StoredSubject]]": declared = {workflow.subject for workflow in cls.workflows() if workflow.subject} for subject_class in declared: if subject_class.subject_type == "transcripts": - raise ExtensionSubjectContractError( + raise AppSubjectContractError( f"{subject_class.__name__} is a 'transcripts' subject; that segment " - "serves every extension's agent-call reads. Name it for what it is" + "serves every app's agent-call reads. Name it for what it is" ) if subject_class.list_summaries.__func__ in stubs: - raise ExtensionSubjectContractError( - f"extension {cls.name!r} declares subject {subject_class.__name__} " + raise AppSubjectContractError( + f"app {cls.name!r} declares subject {subject_class.__name__} " f"without list_summaries(); the board calls it. Implement " f"list_summaries() on {subject_class.__name__}." ) @@ -212,37 +210,37 @@ def subjects(cls) -> "list[type[Subject] | type[StoredSubject]]": @classmethod def discover(cls) -> list[ModuleType]: - """Import the extension's capability modules so its webhooks, workflows, + """Import the app's capability modules so its webhooks, workflows, agents, and subscribers self-register. The default walks ``package``; - override to customize discovery (the Django ``ExtensionConfig.ready`` escape - hatch — the override, not a platform special-case, is how a weird extension + override to customize discovery (the Django ``AppConfig.ready`` escape + hatch — the override, not a platform special-case, is how a weird app stays weird). Returns the imported modules so ``get_routers`` can read the routers off the ``routes`` ones.""" return autodiscover(cls.package) @classmethod def capability_modules(cls) -> list[ModuleType]: - """The extension's imported capability modules — the ``routes``, + """The app's imported capability modules — the ``routes``, ``subscribers``, ``workflows``, and ``webhooks`` leaves ``discover`` - walks. Enumerates the extension's route/subscriber/webhook surface - app-lessly (each ``@subscribe`` and ``Webhook`` self-registers on + walks. Enumerates the app's route/subscriber/webhook surface + headlessly (each ``@subscribe`` and ``Webhook`` self-registers on import, so the modules are the surface); an alias for ``discover`` read as a surface rather than a side effect.""" return cls.discover() @classmethod def routers(cls) -> "list[APIRouter]": - """Every router the extension mounts, enumerated without the web app — + """Every router the app mounts, enumerated without the web app — its declared ``routes`` routers plus the free read-sides. Builds the ``APIRouter`` objects but constructs no FastAPI app, so a CLI or eval can - read the extension's route surface without booting the platform.""" + read the app's route surface without booting the platform.""" return cls.get_routers(cls.discover()) @classmethod def migrations_dir(cls) -> Path | None: - """The extension's own migration history root (``/migrations``), + """The app's own migration history root (``/migrations``), or None when it ships no migrations — a builtin whose tables live in - core's schema, or a not-yet-migrated extension. The ``versions/`` dir it + core's schema, or a not-yet-migrated app. The ``versions/`` dir it contains is what ``druks init-db`` upgrades under ``alembic_version_``.""" package_dir = cls.package_dir() @@ -253,7 +251,7 @@ def migrations_dir(cls) -> Path | None: @classmethod def package_dir(cls) -> Path | None: - """Filesystem root of ``package`` — where the extension's shipped non-module + """Filesystem root of ``package`` — where the app's shipped non-module assets (``migrations/``, ``dist/``) live. None when the package has no location (a namespace-less or frozen import).""" spec = importlib.util.find_spec(cls.package) @@ -263,7 +261,7 @@ def package_dir(cls) -> Path | None: @classmethod def frontend_dist(cls) -> Path | None: - """The extension's built frontend (``/dist``), if it ships one. + """The app's built frontend (``/dist``), if it ships one. A dist is an ESM module the shell mounts, not a document — ``entry.js`` is its marker. Inside the package, not the project root, so the same path resolves for a wheel and an editable install alike.""" @@ -275,14 +273,14 @@ def frontend_dist(cls) -> Path | None: @classmethod def get_routers(cls, modules: list[ModuleType]) -> "list[APIRouter]": - """Every router mounted under the extension's namespace: the ones it declares in + """Every router mounted under the app's namespace: the ones it declares in its ``routes`` modules, plus the generic read-side it gets for free — ``/transcripts`` always, and one subject read-side (``/`` → status + timeline + live stream) per subject its workflows declare. The platform's come first: those segments are its own. Override to add a router built outside a ``routes`` module.""" # Local, not module-top: keeps FastAPI off the import graph so the loader - # stays importable app-lessly; enumerating routers is where it's really needed. + # stays importable headlessly; enumerating routers is where it's really needed. from fastapi import APIRouter seen: set[int] = set() @@ -295,7 +293,7 @@ def get_routers(cls, modules: list[ModuleType]) -> "list[APIRouter]": seen.add(id(value)) declared.append(value) # The platform's routers are narrow — each confined to its own segment — so - # matching them first costs an extension nothing anywhere else and leaves it + # matching them first costs an app nothing anywhere else and leaves it # no way to take a read the platform serves, not even with a catch-all. return [ cls._get_transcript_routes(), @@ -305,13 +303,13 @@ def get_routers(cls, modules: list[ModuleType]) -> "list[APIRouter]": @classmethod def _get_transcript_routes(cls) -> "APIRouter": - """The agent-call read-side every extension gets for free: a paginated read and a + """The agent-call read-side every app gets for free: a paginated read and a live tail of a call's stdout/stderr, plus its artifact files (prompt, response, transcript streams, metadata) listed and downloadable. Keyed by the platform's own - ``AgentCall`` and mounted under ``/api//transcripts`` — an extension writes + ``AgentCall`` and mounted under ``/api//transcripts`` — an app writes none of it.""" # Keep FastAPI and the durable read-side off the import graph so the loader - # stays importable app-lessly. + # stays importable headlessly. import mimetypes from typing import Literal @@ -396,8 +394,8 @@ def _get_subject_routes( ) -> "APIRouter": """The board and one subject (header + status + timeline + activity), each with a point-in-time read and a ``/stream`` that pushes the whole snapshot on change. - Mounted at ``/api//`` for every subject the extension's - workflows declare. Every read here is keyed by identity, so an extension that + Mounted at ``/api//`` for every subject the app's + workflows declare. Every read here is keyed by identity, so an app that keeps no row for its subject gets the same surface as one that does. The board reads pass the caller to ``list_summaries``.""" from fastapi import APIRouter, HTTPException, status @@ -479,9 +477,9 @@ async def read_subject(subject_id: str) -> SubjectResponse: @classmethod async def on_startup(cls) -> None: - """Converge runtime state when the API process boots — after extension load - and DBOS launch. Default no-op; an extension overrides it to sync schedules or - similar. The caller logs a failure and moves on, so one extension can't wedge + """Converge runtime state when the API process boots — after app load + and DBOS launch. Default no-op; an app overrides it to sync schedules or + similar. The caller logs a failure and moves on, so one app can't wedge boot.""" @classmethod @@ -492,16 +490,16 @@ def record_event( subject: "Subject | StoredSubject | None" = None, payload: dict[str, Any] | None = None, ) -> None: - """Record one of this extension's domain events to the log, stamped with the - extension automatically. Apps record through here so the ``Event`` model + """Record one of this app's domain events to the log, stamped with the + app automatically. Apps record through here so the ``Event`` model stays a platform internal. ``type`` is the milestone's own word ("merged") — - the feed reads it as one, so an extension writes no rendering.""" + the feed reads it as one, so an app writes no rendering.""" Event.emit( type=type, subject=subject.identity if subject else None, label=subject.label if subject else None, payload=payload, - extension=cls.name, + app=cls.name, ) @classmethod diff --git a/backend/druks/extensions/config.py b/backend/druks/apps/config.py similarity index 54% rename from backend/druks/extensions/config.py rename to backend/druks/apps/config.py index 8365cd0a..ddc280b2 100644 --- a/backend/druks/extensions/config.py +++ b/backend/druks/apps/config.py @@ -3,21 +3,19 @@ import yaml from pydantic import BaseModel, ValidationError -from .exceptions import ExtensionConfigError +from .exceptions import AppConfigError from .fetcher import fetch_file ModelT = TypeVar("ModelT", bound=BaseModel) -async def resolve_extension_config( - extension: str, *, repo: str | None, model: type[ModelT] -) -> ModelT: - """Load ``.druks//config.yml`` from the target repo, validated +async def resolve_app_config(app: str, *, repo: str | None, model: type[ModelT]) -> ModelT: + """Load ``.druks//config.yml`` from the target repo, validated against the model.""" merged: dict[str, Any] = {} - if repo and (raw := await fetch_file(repo=repo, path=f".druks/{extension}/config.yml")): + if repo and (raw := await fetch_file(repo=repo, path=f".druks/{app}/config.yml")): merged.update(yaml.safe_load(raw) or {}) try: return model.model_validate(merged) except ValidationError as error: - raise ExtensionConfigError(f"invalid {extension} config for {repo}: {error}") from error + raise AppConfigError(f"invalid {app} config for {repo}: {error}") from error diff --git a/backend/druks/apps/exceptions.py b/backend/druks/apps/exceptions.py new file mode 100644 index 00000000..9eca1d5b --- /dev/null +++ b/backend/druks/apps/exceptions.py @@ -0,0 +1,45 @@ +class AppConfigError(Exception): + """An app's ``.druks`` config could not be parsed or validated. + + Raised at intake (and by push-time validation) so bad config fails + loudly where the work starts instead of half-applying.""" + + +class SettingsDeclarationError(Exception): + """A ``Settings`` inner class declares a field the settings plane can't render or + validate — e.g. a nested model. Raised at declaration (app/workflow subclass + creation) so a bad settings shape fails loudly where it's written, not at the first + operator PATCH.""" + + +class SubscriberDeclarationError(Exception): + """A subscriber's signature asks for a routing key — one a filter matches on but + no body is handed. Raised at declaration, so it fails on import instead of + inside the durable step that publishes the signal.""" + + +class AppLoadError(Exception): + """An app could not be loaded, headlessly or at full boot. The concrete + subclass names the failed stage; nothing raises this base directly.""" + + +class AppNotFound(AppLoadError): + """No installed app declares the requested name under the + ``druks.apps`` entry-point group — the package isn't installed.""" + + +class MalformedApp(AppLoadError): + """The app's entry point resolves to something that isn't an + ``App`` subclass, or its metadata target can't be resolved at all — + a packaging mistake, not a runtime error inside the app.""" + + +class AppImportError(AppLoadError): + """Importing the app's models or capability modules raised. The + app is installed and well-declared, but its own code failed on + import — carries the original exception as its cause.""" + + +class AppSubjectContractError(AppLoadError): + """A declared subject fails the read-side contract: no ``list_summaries()`` + implementation, or it names the reserved ``transcripts`` segment.""" diff --git a/backend/druks/extensions/fetcher.py b/backend/druks/apps/fetcher.py similarity index 100% rename from backend/druks/extensions/fetcher.py rename to backend/druks/apps/fetcher.py diff --git a/backend/druks/apps/loader.py b/backend/druks/apps/loader.py new file mode 100644 index 00000000..f5db2bca --- /dev/null +++ b/backend/druks/apps/loader.py @@ -0,0 +1,250 @@ +from importlib.metadata import entry_points +from typing import TYPE_CHECKING + +from druks.apps import App + +from .exceptions import AppImportError, AppNotFound, MalformedApp + +if TYPE_CHECKING: + from importlib.metadata import EntryPoint + from types import ModuleType + + from fastapi import FastAPI + +_GROUP = "druks.apps" + +# Which app owns each workflow-declaring package — the loader-validated +# installation claims, stamped once an entry point checks out, so a Workflow +# class resolves its identity at definition time. None marks a package whose +# workflows belong to no app (how test modules register themselves). +_workflow_packages: dict[str, str | None] = {} + + +def register_workflow_package(package: str, app: str | None) -> None: + # Conflicting or overlapping claims are a packaging mistake — two installs + # can't share a workflow package. + if package in _workflow_packages: + if _workflow_packages[package] != app: + raise MalformedApp( + f"package {package!r} already belongs to " + f"{_workflow_packages[package]!r} — {app!r} can't claim it" + ) + return + for registered, owner in _workflow_packages.items(): + nested = registered.startswith(f"{package}.") or package.startswith(f"{registered}.") + if nested and owner != app: + raise MalformedApp( + f"package {package!r} overlaps {registered!r} (owned by {owner!r}) — " + "workflow ownership must be unambiguous" + ) + _workflow_packages[package] = app + + +def resolve_workflow_app(module: str) -> str | None: + """The app owning ``module``'s nearest registered ancestor package. + Raises ``LookupError`` when no registered package contains the module.""" + prefix = module + while prefix: + if prefix in _workflow_packages: + return _workflow_packages[prefix] + prefix = prefix.rpartition(".")[0] + raise LookupError(module) + + +def iter_apps() -> list[type[App]]: + """Every installed app, resolved from the ``druks.apps`` entry points. + Loading an entry point imports the app's class. An app that fails to + import, resolves to a non-``App``, or collides on ``name`` raises — there + is no per-app fault tolerance yet (deferred until a real external app + exists).""" + apps: list[type[App]] = [] + seen: set[str] = set() + for entry in entry_points(group=_GROUP): + app = entry.load() + if not (isinstance(app, type) and issubclass(app, App)): + raise TypeError(f"app entry point {entry.name!r} is not an App") + if app.name != entry.name: + raise MalformedApp( + f"app {entry.name!r} entry point resolves to an App named " + f"{app.name!r} — the entry-point name must match App.name" + ) + if app.name in seen: + raise ValueError(f"duplicate app name {app.name!r}") + seen.add(app.name) + # Ownership registers before discover() imports the capability modules, + # whose Workflow classes resolve their app at definition. + register_workflow_package(app.package, app.name) + apps.append(app) + return apps + + +def get_app(name: str) -> type[App]: + for app in iter_apps(): + if app.name == name: + return app + raise KeyError(f"no installed app named {name!r}") + + +def load_app(name: str) -> type[App]: + """Load one installed app without the web app: resolve its entry point, + register its tables, and import its capability modules so its workflows, + routes, subscribers, and webhooks self-register. Returns the ``App`` + class, with every surface then enumerable off it (``workflows()``, + ``routers()``, ``capability_modules()``, ``settings_model``, + ``migrations_dir()``). The load path used by the CLI, tests, and evals — + no FastAPI, nothing mounted. + + Fails loudly and by name: an uninstalled package raises ``AppNotFound``; + an entry point that doesn't resolve to an ``App`` raises + ``MalformedApp``; the app's own code raising on import raises + ``AppImportError``; a declared subject that fails the read-side contract + raises ``AppSubjectContractError``.""" + app = _resolve(name) + try: + import_app_models(app) + app.discover() + except Exception as error: + raise AppImportError(f"app {name!r} failed to import: {error}") from error + # Outside the try: a contract break is not an import error. + app.subjects() + return app + + +def _resolve(name: str) -> type[App]: + """The single installed app named ``name``, loaded to its ``App`` + class. Entry points are listed, not imported, so an unknown name fails before + any app code runs.""" + matches = [e for e in entry_points(group=_GROUP) if e.name == name] + if not matches: + raise AppNotFound(f"no installed app named {name!r} — install its package first") + if len(matches) > 1: + # Two installed distributions register the same entry-point key — the name + # keys the /api, settings, and migration namespaces, so this is a broken + # install. Caught from metadata alone, without importing anything. + raise MalformedApp( + f"app {name!r} is declared by {len(matches)} installed packages " + f"({', '.join(e.value for e in matches)}) — uninstall all but one" + ) + app = _load_entry(matches[0]) + # The entry-point key must equal the class's ``name`` — the key scopes the + # /api, settings, and migration namespaces, which is what lets the + # duplicate-key check above stand in for a duplicate-name check without + # importing sibling apps. + if app.name != name: + raise MalformedApp( + f"app {name!r} entry point resolves to an App named " + f"{app.name!r} — the entry-point name must match App.name" + ) + register_workflow_package(app.package, app.name) + return app + + +def _load_entry(entry: "EntryPoint") -> type[App]: + """Resolve one entry point to its ``App`` class, distinguishing a + packaging mistake from the app's own code failing on import. A missing + target module or attribute is ``MalformedApp`` (bad metadata); the + target module existing but raising on import is ``AppImportError`` (the + app's code) — the two the caller's taxonomy must tell apart.""" + import importlib + + try: + module = importlib.import_module(entry.module) + except ModuleNotFoundError as error: + if error.name == entry.module or entry.module.startswith(f"{error.name}."): + raise MalformedApp( + f"entry point {entry.value!r} points at a module that isn't installed: {error}" + ) from error + # A dependency the entry module imports is missing — its code ran and failed. + raise AppImportError( + f"app entry module {entry.module!r} failed to import: {error}" + ) from error + except Exception as error: + raise AppImportError( + f"app entry module {entry.module!r} failed to import: {error}" + ) from error + + app = module + for attribute in filter(None, (entry.attr or "").split(".")): + try: + app = getattr(app, attribute) + except AttributeError as error: + raise MalformedApp( + f"entry point {entry.value!r} names {attribute!r}, which its module doesn't define" + ) from error + if not (isinstance(app, type) and issubclass(app, App)): + raise MalformedApp(f"entry point {entry.value!r} is not an App") + return app + + +def _tables_declared_in(package: str) -> set[str]: + # A table belongs to whoever declared its model, not to whoever happened to import + # it first: an app entry module pulls its own models in, and a sibling's. + from druks.models import Base + + return { + mapper.local_table.name + for mapper in Base.registry.mappers + if mapper.class_.__module__.startswith(f"{package}.") and mapper.local_table is not None + } + + +def import_app_models(only: type[App] | None = None) -> None: + """Import apps' ``.models`` so their tables register on the shared + metadata before ``create_all`` or autogenerate. Defaults to every installed + app (boot, migrations); pass ``only`` to register a single app's tables + for a headless load. A separately-shipped app's tables must carry its + ``_`` prefix — the platform scopes its migrations by that prefix, so an + unprefixed table would be invisible to them and fails the load instead. Builtin + apps are exempt: their schema is core's.""" + import importlib + import importlib.util + + apps = [only] if only else iter_apps() + for app in apps: + name = f"{app.package}.models" + if not importlib.util.find_spec(name): + continue + importlib.import_module(name) + misnamed = sorted( + table + for table in _tables_declared_in(app.package) + if not table.startswith(app.table_prefix) + ) + if misnamed and app.prefix_tables and not app.builtin: + raise ValueError( + f"app {app.name!r} tables must start with {app.table_prefix!r}: {misnamed}" + ) + + +def mount(api: "FastAPI", app: type[App], modules: list["ModuleType"]) -> None: + """Mount one discovered app under ``/api/`` and ``/app/``. + The prefix and the identity gate are the loader's — no app hook can + override them.""" + # Local, matching get_routers: the loader stays importable headlessly. + from fastapi import Depends + + from druks.accounts.dependencies import current_account + + prefix = f"/api/{app.name}" + for router in app.get_routers(modules): + api.include_router( + router, + prefix=prefix, + tags=[app.name], + dependencies=[Depends(current_account)], + ) + dist = app.frontend_dist() + if dist: + # /app, not /api: unknown /api/* paths stay JSON 404s. + api.frontend(f"/app/{app.name}", directory=dist) + + +def load(api: "FastAPI") -> None: + """API boot: for each app — discover, validate subjects, mount.""" + # The table-prefix check runs here, not just in makemigrations — an author + # who hand-writes migrations still can't boot with an unprefixed table. + import_app_models() + for app in iter_apps(): + modules = app.discover() + app.subjects() + mount(api, app, modules) diff --git a/backend/druks/extensions/registry.py b/backend/druks/apps/registry.py similarity index 100% rename from backend/druks/extensions/registry.py rename to backend/druks/apps/registry.py diff --git a/backend/druks/apps/routes.py b/backend/druks/apps/routes.py new file mode 100644 index 00000000..f40cc2e1 --- /dev/null +++ b/backend/druks/apps/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from .loader import iter_apps +from .schemas import AppResponse + +router = APIRouter(prefix="/api/apps", tags=["apps"]) + + +@router.get("", response_model=list[AppResponse], response_model_by_alias=True) +async def list_apps() -> list[AppResponse]: + return [ + AppResponse( + name=app.name, + icon=app.icon, + description=app.description, + builtin=app.builtin, + subject_types=[subject.subject_type for subject in app.subjects()], + has_frontend=bool(app.frontend_dist()), + navigation=app.navigation, + ) + for app in iter_apps() + ] diff --git a/backend/druks/extensions/schemas.py b/backend/druks/apps/schemas.py similarity index 64% rename from backend/druks/extensions/schemas.py rename to backend/druks/apps/schemas.py index 8a171bcd..361ed600 100644 --- a/backend/druks/extensions/schemas.py +++ b/backend/druks/apps/schemas.py @@ -1,12 +1,12 @@ from druks.schemas import BaseResponse -class ExtensionResponse(BaseResponse): +class AppResponse(BaseResponse): name: str icon: str description: str builtin: bool subject_types: list[str] has_frontend: bool - # (url, name) pairs, straight from the extension's declaration. + # (url, name) pairs, straight from the app's declaration. navigation: list[tuple[str, str]] diff --git a/backend/druks/extensions/settings.py b/backend/druks/apps/settings.py similarity index 100% rename from backend/druks/extensions/settings.py rename to backend/druks/apps/settings.py diff --git a/backend/druks/browser/exceptions.py b/backend/druks/browser/exceptions.py index dc059002..fdf9cad2 100644 --- a/backend/druks/browser/exceptions.py +++ b/backend/druks/browser/exceptions.py @@ -23,7 +23,7 @@ def __init__(self, name: str, status: str) -> None: class BrowserSessionSignedOutError(FatalError): - # Raised by extension code inside a borrow when the site bounced the login. + # Raised by app code inside a borrow when the site bounced the login. # The door stamps which session; the run fails under this code and announces # the bounce, and a subscriber marks the session stale. Nothing to catch. code = "browser_session_signed_out" @@ -57,7 +57,7 @@ def __init__(self, session_id: str) -> None: class BrowserClientMissingError(Exception): def __init__(self, name: str) -> None: super().__init__( - f"{name}.browser() drives the session with playwright, which the extension " + f"{name}.browser() drives the session with playwright, which the app " "supplies — add playwright to its dependencies, or use .cdp() with another client." ) diff --git a/backend/druks/browser/routes.py b/backend/druks/browser/routes.py index b8597d15..dbde6d45 100644 --- a/backend/druks/browser/routes.py +++ b/backend/druks/browser/routes.py @@ -9,6 +9,7 @@ require_operator, ) from druks.accounts.models import Account +from druks.apps.registry import browser_sessions from druks.browser import exceptions from druks.browser.constants import MAX_PAYLOAD_BYTES, PAYLOAD_WARNING_BYTES from druks.browser.enums import BrowserSessionPayloadFormat, BrowserSessionStatus @@ -16,7 +17,6 @@ from druks.browser.models import StoredBrowserSession from druks.browser.schemas import BrowserSessionResponse from druks.database import session_scope -from druks.extensions.registry import browser_sessions logger = logging.getLogger(__name__) diff --git a/backend/druks/browser/sessions.py b/backend/druks/browser/sessions.py index 14193928..5ad108f4 100644 --- a/backend/druks/browser/sessions.py +++ b/backend/druks/browser/sessions.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from pathlib import Path +from druks.apps.registry import browser_sessions from druks.browser.constants import ( SESSION_EXPORT_TIMEOUT_SECONDS, SESSION_LAUNCH_TIMEOUT_SECONDS, @@ -18,7 +19,6 @@ ) from druks.browser.locks import acquire_writer_lock, release_writer_lock from druks.browser.models import StoredBrowserSession -from druks.extensions.registry import browser_sessions from druks.sandbox.client import sandbox_client from druks.settings import load_settings @@ -28,10 +28,10 @@ @dataclass class BrowserSession: - """A named browser login the extension's runs borrow. + """A named browser login the app's runs borrow. - Declared on the Extension class — ``acme = BrowserSession(site="acme.example")`` - — the attribute name and the extension's name become the session's identity + Declared on the App class — ``acme = BrowserSession(site="acme.example")`` + — the attribute name and the app's name become the session's identity (``night_watch.acme``). The operator signs in once through the login window; a workflow then borrows the logged-in browser:: @@ -73,7 +73,7 @@ async def cdp(self): try: yield f"http://127.0.0.1:{listener.get_port()}" except BrowserSessionSignedOutError as error: - # The extension says the login bounced; only the door knows + # The app says the login bounced; only the door knows # which session that was. The run machinery does the rest. error.session_name = self.name raise @@ -91,7 +91,7 @@ async def playwright(self): """The logged-in browser context, driven with playwright — the usual door. The login lives in this context, so pages opened on it (``await browser.new_page()``) are signed in. Playwright comes from - the extension's own dependencies; ``cdp()`` yields the raw url for any + the app's own dependencies; ``cdp()`` yields the raw url for any other client.""" try: import playwright.async_api as playwright_api # pyright: ignore[reportMissingImports] diff --git a/backend/druks/cli.py b/backend/druks/cli.py index 7815585f..a06da1ad 100644 --- a/backend/druks/cli.py +++ b/backend/druks/cli.py @@ -1,6 +1,6 @@ import argparse -from .database import create_engine_from_url, make_extension_migration, run_migrations +from .database import create_engine_from_url, make_app_migration, run_migrations from .settings import ensure_data_dirs, load_settings, setup_logging @@ -10,9 +10,9 @@ def main() -> None: subparsers.add_parser("init-db") makemigrations = subparsers.add_parser( "makemigrations", - help="Autogenerate a migration for an installed extension into its own versions/.", + help="Autogenerate a migration for an installed app into its own versions/.", ) - makemigrations.add_argument("extension", help="The installed extension's name.") + makemigrations.add_argument("app", help="The installed app's name.") makemigrations.add_argument("-m", "--message", default="", help="Revision message (slug).") doctor_parser = subparsers.add_parser( "doctor", @@ -60,15 +60,15 @@ def main() -> None: ) create_parser = subparsers.add_parser("create", help="Scaffold a new druks artifact.") create_subparsers = create_parser.add_subparsers(dest="artifact", required=True) - create_extension_parser = create_subparsers.add_parser( - "extension", + create_app_parser = create_subparsers.add_parser( + "app", help=( - "Scaffold a standalone extension package at ./druks-: a " - "registered Extension subclass, an /api/ router, and its own " + "Scaffold a standalone app package at ./druks-: a " + "registered App subclass, an /api/ router, and its own " "Alembic history — bootable once installed." ), ) - create_extension_parser.add_argument( + create_app_parser.add_argument( "name", help="Lowercase identifier ([a-z][a-z0-9_]*) — keys /api/ and more." ) args = parser.parse_args() @@ -104,10 +104,10 @@ def main() -> None: if args.command == "create": from pathlib import Path - from .scaffolding import create_extension + from .scaffolding import create_app try: - target = create_extension(args.name, Path.cwd()) + target = create_app(args.name, Path.cwd()) except ValueError as error: raise SystemExit(f"druks create: {error}") from error print(f"Created {target}") @@ -131,7 +131,7 @@ def main() -> None: return if args.command == "makemigrations": - make_extension_migration(args.extension, args.message, settings.database_url) + make_app_migration(args.app, args.message, settings.database_url) return raise AssertionError(f"Unhandled command: {args.command}") diff --git a/backend/druks/contrib/review/extension.py b/backend/druks/contrib/review/app.py similarity index 88% rename from backend/druks/contrib/review/extension.py rename to backend/druks/contrib/review/app.py index f801fa67..a4dd5660 100644 --- a/backend/druks/contrib/review/extension.py +++ b/backend/druks/contrib/review/app.py @@ -1,9 +1,9 @@ from pydantic import Field from druks.agents import Agent +from druks.apps import App, AppSettings, Secret from druks.contrib.review.contracts import ReviewReport from druks.doctor import CheckResult -from druks.extensions import Extension, ExtensionSettings, Secret def check_review_identity() -> CheckResult: @@ -18,7 +18,7 @@ def check_review_identity() -> CheckResult: return CheckResult(name="identity", ok=True, detail="unset — reviews post as operator comments") -class Review(Extension): +class Review(App): name = "review" icon = "git-pull-request" description = ( @@ -26,11 +26,11 @@ class Review(Extension): "a summary, and a comment on each line it has something to say about." ) - class Settings(ExtensionSettings): + class Settings(AppSettings): # The optional distinct review identity. Empty pair — reviews borrow # the operator client in comment mode; complete pair — verdict reviews - # post as this App. Extension-owned by design: a posting identity for - # one extension is not a platform service identity. + # post as this App. App-owned by design: a posting identity for + # one app is not a platform service identity. app_id: Secret = Field( title="Review App ID", description="GitHub App ID of the distinct review identity; empty posts as comments.", diff --git a/backend/druks/contrib/review/github.py b/backend/druks/contrib/review/github.py index 48df78bc..ca30e713 100644 --- a/backend/druks/contrib/review/github.py +++ b/backend/druks/contrib/review/github.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Literal -from druks.contrib.review.extension import Review +from druks.contrib.review.app import Review from druks.core.apis.github import GitHubClient, get_github_client from druks.settings import load_settings diff --git a/backend/druks/contrib/review/workflows.py b/backend/druks/contrib/review/workflows.py index 4e1ea2f7..830f5921 100644 --- a/backend/druks/contrib/review/workflows.py +++ b/backend/druks/contrib/review/workflows.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING, Any from druks.accounts.models import Account +from druks.contrib.review.app import Review from druks.contrib.review.datastructures import PullRequest -from druks.contrib.review.extension import Review from druks.contrib.review.github import get_review_actor from druks.contrib.ship.models import ProjectRepo from druks.contrib.ship.workspace import RepoWorkspace diff --git a/backend/druks/contrib/ship/extension.py b/backend/druks/contrib/ship/app.py similarity index 97% rename from backend/druks/contrib/ship/extension.py rename to backend/druks/contrib/ship/app.py index a43d6818..cc8d7e21 100644 --- a/backend/druks/contrib/ship/extension.py +++ b/backend/druks/contrib/ship/app.py @@ -3,6 +3,7 @@ from pydantic import Field from druks.agents import Agent +from druks.apps import App, AppSettings from druks.contrib.ship.contracts import ( ContractRevisionOutput, EvaluationOutput, @@ -18,7 +19,6 @@ from druks.core import services from druks.db import StoredSubject from druks.doctor import CheckResult -from druks.extensions import Extension, ExtensionSettings from druks.services import ServiceNotConnectedError from druks.workflows import SubjectActivity @@ -47,7 +47,7 @@ def check_tracker_identity() -> CheckResult: ) -class Ship(Extension): +class Ship(App): name = "ship" # These tables (projects, work_items, ...) are already unprefixed in core's # migration history, so they must stay that way. @@ -60,7 +60,7 @@ class Ship(Extension): ) navigation = [("/ship", "active"), ("/ship/history", "history"), ("/ship/projects", "projects")] - class Settings(ExtensionSettings): + class Settings(AppSettings): tracker: Literal["none", "linear", "jira"] = Field( default="linear", title="Tracker", @@ -138,7 +138,7 @@ def get_tracker(cls, source: str | None = None) -> Tracker | None: except ServiceNotConnectedError: return - # The build pipeline's agents — the extension owns them; any of its workflows run + # The build pipeline's agents — the app owns them; any of its workflows run # them. The attribute name is each agent's id (its durable settings/timeline key). generate_plan = Agent( description="ticket → implementation plan", diff --git a/backend/druks/contrib/ship/models.py b/backend/druks/contrib/ship/models.py index 9be838e0..adee5f96 100644 --- a/backend/druks/contrib/ship/models.py +++ b/backend/druks/contrib/ship/models.py @@ -71,7 +71,7 @@ class ProjectRepo(StoredSubject): ) full_name: Mapped[str] = mapped_column(unique=True) # Optional free-form role for the dashboard: "design", "infra", - # "extension". None when the operator hasn't labelled it. + # "app". None when the operator hasn't labelled it. purpose: Mapped[str | None] profile: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) @@ -301,13 +301,13 @@ def start_attempt(self) -> None: db_session().flush() def resolve(self, *, merged: bool, at: datetime) -> None: - # cycle: the extension imports this module at file scope. - import druks.contrib.ship.extension as ship_extension + # cycle: the app imports this module at file scope. + import druks.contrib.ship.app as ship_app self.resolution = "merged" if merged else "closed" self.resolved_at = at self.updated_at = Base.utc_now() - ship_extension.Ship.record_event(type=self.resolution, subject=self) + ship_app.Ship.record_event(type=self.resolution, subject=self) db_session().flush() async def ship(self) -> None: @@ -363,10 +363,10 @@ def list_handoff(cls, *, limit: int = 10) -> list["WorkItem"]: return list(db_session().scalars(stmt)) async def set_ticket_status(self, status: TicketStatus) -> None: - # Lazy: the Ship extension imports this module, so it can't be imported at top. - import druks.contrib.ship.extension as ship_extension + # Lazy: the Ship app imports this module, so it can't be imported at top. + import druks.contrib.ship.app as ship_app - tracker = ship_extension.Ship.get_tracker(self.source) + tracker = ship_app.Ship.get_tracker(self.source) # No tracker means nothing to sync: a github item, a source the operator # has switched away from, or credentials not set yet. if not tracker: diff --git a/backend/druks/contrib/ship/policy.py b/backend/druks/contrib/ship/policy.py index 30efa588..9ecc7e98 100644 --- a/backend/druks/contrib/ship/policy.py +++ b/backend/druks/contrib/ship/policy.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from druks.extensions.config import resolve_extension_config +from druks.apps.config import resolve_app_config from druks.prompts import render_prompt from druks.sandbox.datastructures import Profile @@ -59,7 +59,7 @@ class RepoPolicy(BaseModel): @classmethod async def resolve(cls, repo: str | None) -> "RepoPolicy": - return await resolve_extension_config("ship", repo=repo, model=cls) + return await resolve_app_config("ship", repo=repo, model=cls) def plan_approval_gate(self, workflow_setting: PlanGate) -> PlanGate: return self.gates.plan_approval or workflow_setting diff --git a/backend/druks/contrib/ship/routes.py b/backend/druks/contrib/ship/routes.py index 51012599..f80172f1 100644 --- a/backend/druks/contrib/ship/routes.py +++ b/backend/druks/contrib/ship/routes.py @@ -6,8 +6,8 @@ from druks.accounts.dependencies import current_account from druks.accounts.models import Account from druks.api.exceptions import agent_error_responses +from druks.contrib.ship.app import Ship from druks.contrib.ship.exceptions import TicketNotFound, TrackerNotConfigured -from druks.contrib.ship.extension import Ship from druks.contrib.ship.models import Project, ProjectRepo, WorkItem from druks.contrib.ship.schemas import ( AddProjectRepoRequest, diff --git a/backend/druks/contrib/ship/subscribers.py b/backend/druks/contrib/ship/subscribers.py index f0ded67f..9a79ea3c 100644 --- a/backend/druks/contrib/ship/subscribers.py +++ b/backend/druks/contrib/ship/subscribers.py @@ -1,5 +1,5 @@ +from druks.contrib.ship.app import Ship from druks.contrib.ship.contracts import ReviewWork -from druks.contrib.ship.extension import Ship from druks.contrib.ship.models import ProjectRepo, WorkItem from druks.contrib.ship.ticketing.enums import TicketStatus from druks.contrib.ship.workflows import Build, Profile diff --git a/backend/druks/contrib/ship/workflows.py b/backend/druks/contrib/ship/workflows.py index 00333994..ffe04d2f 100644 --- a/backend/druks/contrib/ship/workflows.py +++ b/backend/druks/contrib/ship/workflows.py @@ -28,8 +28,8 @@ from druks.skills.models import Skill from druks.workflows import FatalError, Workflow, step +from .app import Ship from .constants import GITHUB_MCP_NAME, GITHUB_MCP_URL -from .extension import Ship from .journal import BuildJournal from .policy import PlanGate, RepoPolicy from .prompt_context import BuildPromptContext diff --git a/backend/druks/core/extension.py b/backend/druks/core/app.py similarity index 52% rename from backend/druks/core/extension.py rename to backend/druks/core/app.py index 59e95dc5..24d72a54 100644 --- a/backend/druks/core/extension.py +++ b/backend/druks/core/app.py @@ -1,10 +1,10 @@ -from druks.extensions import Extension +from druks.apps import App -class Core(Extension): - """The platform's home extension: the webhook providers and the platform +class Core(App): + """The platform's home app: the webhook providers and the platform chores (token refresh, stale-call and sandbox-host reaping) — the package - walk discovers them like any extension's capabilities.""" + walk discovers them like any app's capabilities.""" name = "core" icon = "hexagon" diff --git a/backend/druks/core/routes.py b/backend/druks/core/routes.py index a167ed22..c16061c1 100644 --- a/backend/druks/core/routes.py +++ b/backend/druks/core/routes.py @@ -10,7 +10,7 @@ from druks.core.templates import render_page from druks.services.models import ServiceIdentity -# Mounted by the loader under /api/core, like any extension's routes. +# Mounted by the loader under /api/core, like any app's routes. router = APIRouter(prefix="/github", tags=["services"]) diff --git a/backend/druks/database.py b/backend/druks/database.py index aa37929d..e5223828 100644 --- a/backend/druks/database.py +++ b/backend/druks/database.py @@ -11,8 +11,8 @@ def run_migrations(database_url: str) -> None: """Bring the schema to head — the migrate container's job (``druks - init-db``). Core first, then each installed extension's own migrations: an - external extension owns an independent history, so this order is the contract, + init-db``). Core first, then each installed app's own migrations: an + external app owns an independent history, so this order is the contract, not a cross-repo revision link. Production schema is owned by Alembic.""" from alembic import command from alembic.config import Config @@ -20,59 +20,59 @@ def run_migrations(database_url: str) -> None: core = Config(str(_ALEMBIC_INI)) core.set_main_option("sqlalchemy.url", database_url) command.upgrade(core, "head") - for name, migrations_dir in _extension_migration_dirs(): - # The extension runs through the platform env (the shared ``alembic.ini`` + for name, migrations_dir in _app_migration_dirs(): + # The app runs through the platform env (the shared ``alembic.ini`` # script_location); only its own revisions (version_locations) and version - # table belong to the extension. - extension_config = Config(str(_ALEMBIC_INI)) - extension_config.set_main_option("version_locations", str(migrations_dir / "versions")) - extension_config.set_main_option("sqlalchemy.url", database_url) - # Own version table per history, else the extension reads core's head from the + # table belong to the app. + app_config = Config(str(_ALEMBIC_INI)) + app_config.set_main_option("version_locations", str(migrations_dir / "versions")) + app_config.set_main_option("sqlalchemy.url", database_url) + # Own version table per history, else the app reads core's head from the # shared default ``alembic_version`` and can't locate it in its own scripts. - extension_config.attributes["version_table"] = f"alembic_version_{name}" - command.upgrade(extension_config, "head") + app_config.attributes["version_table"] = f"alembic_version_{name}" + command.upgrade(app_config, "head") -def make_extension_migration(extension_name: str, message: str, database_url: str) -> None: - """Autogenerate a revision for one installed extension into its own ``versions/``, - diffing the extension's prefix-scoped tables against the live DB. The dev DB must be - at the extension's head first (``druks init-db``) — Alembic diffs models against the +def make_app_migration(app_name: str, message: str, database_url: str) -> None: + """Autogenerate a revision for one installed app into its own ``versions/``, + diffing the app's prefix-scoped tables against the live DB. The dev DB must be + at the app's head first (``druks init-db``) — Alembic diffs models against the database, not migration state.""" from alembic import command from alembic.config import Config from sqlalchemy import MetaData - from druks.extensions.loader import get_extension, import_extension_models + from druks.apps.loader import get_app, import_app_models from druks.models import Base - import_extension_models() - extension = get_extension(extension_name) - package_dir = extension.package_dir() + import_app_models() + app = get_app(app_name) + package_dir = app.package_dir() if not package_dir: - raise ValueError(f"extension {extension_name!r} ships no package to write migrations into") + raise ValueError(f"app {app_name!r} ships no package to write migrations into") migrations_dir = package_dir / "migrations" scoped = MetaData() for table in Base.metadata.tables.values(): - if table.name.startswith(extension.table_prefix): + if table.name.startswith(app.table_prefix): table.to_metadata(scoped) config = Config(str(_ALEMBIC_INI)) config.set_main_option("version_locations", str(migrations_dir / "versions")) config.set_main_option("sqlalchemy.url", database_url) - config.attributes["version_table"] = f"alembic_version_{extension.name}" + config.attributes["version_table"] = f"alembic_version_{app.name}" config.attributes["target_metadata"] = scoped command.revision(config, message=message, autogenerate=True) -def _extension_migration_dirs() -> list[tuple[str, Path]]: - from druks.extensions.loader import iter_extensions +def _app_migration_dirs() -> list[tuple[str, Path]]: + from druks.apps.loader import iter_apps found: list[tuple[str, Path]] = [] - for extension in iter_extensions(): - package_dir = extension.package_dir() + for app in iter_apps(): + package_dir = app.package_dir() if package_dir and (package_dir / "migrations" / "versions").is_dir(): - found.append((extension.name, package_dir / "migrations")) + found.append((app.name, package_dir / "migrations")) return found diff --git a/backend/druks/db.py b/backend/druks/db.py index 0aa2851c..338e65e1 100644 --- a/backend/druks/db.py +++ b/backend/druks/db.py @@ -1,4 +1,4 @@ -# Author-facing facade: the DB surface extension code is meant to import. +# Author-facing facade: the DB surface app code is meant to import. # Platform/core modules import druks.database directly instead. from druks.database import db_session from druks.models import Base, StoredSubject diff --git a/backend/druks/doctor.py b/backend/druks/doctor.py index 1f65f53e..7afeeb07 100644 --- a/backend/druks/doctor.py +++ b/backend/druks/doctor.py @@ -16,10 +16,10 @@ from sqlalchemy.orm import Session from .agents import Agent +from .apps.loader import iter_apps +from .apps.registry import _ROLES, agents, autodiscover, services, webhooks, workflows from .core.apis.github import get_github_client from .database import create_engine_from_url, db_session -from .extensions.loader import iter_extensions -from .extensions.registry import _ROLES, agents, autodiscover, services, webhooks, workflows from .harnesses.models import HarnessConnection from .harnesses.registry import get_harnesses from .sandbox.client import sandbox_client @@ -47,8 +47,8 @@ def check_service_identities(settings: Settings) -> list[CheckResult]: without editing doctor. Declarations self-register through the same discovery walk the loader runs. Identities are database-backed like harness connections — this reports each row's presence, not a file or setting.""" - for extension in iter_extensions(): - extension.discover() + for app in iter_apps(): + app.discover() engine = create_engine_from_url(settings.database_url) try: with Session(engine) as session: @@ -361,7 +361,7 @@ def check_capability_modules(settings: Settings) -> CheckResult: "agents": agents, "services": services, } - packages = [extension.package for extension in iter_extensions()] + packages = [app.package for app in iter_apps()] strays: list[str] = [] for package in packages: # The canonical walk first, then snapshot what it registered — so a @@ -393,20 +393,20 @@ def check_capability_modules(settings: Settings) -> CheckResult: ) -def check_extensions(settings: Settings) -> list[CheckResult]: - """Each installed extension's resolved settings and own checks, namespaced under it. - Read off the class app-lessly through the loader, so doctor never imports an - extension's private modules. A check or settings clean that raises is contained - under the extension's name, and core checks remain separate ``CHECKS`` entries.""" +def check_apps(settings: Settings) -> list[CheckResult]: + """Each installed app's resolved settings and own checks, namespaced under it. + Read off the class headlessly through the loader, so doctor never imports an + app's private modules. A check or settings clean that raises is contained + under the app's name, and core checks remain separate ``CHECKS`` entries.""" engine = create_engine_from_url(settings.database_url) try: with Session(engine) as session: db_session.registry.set(session) results: list[CheckResult] = [] - for extension in iter_extensions(): - if settings_model := extension.settings_model: + for app in iter_apps(): + if settings_model := app.settings_model: try: - problems = extension.settings().clean() + problems = app.settings().clean() detail = "; ".join( f"{settings_model.model_fields[field].title or field}: {message}" for field, message in problems.items() @@ -414,7 +414,7 @@ def check_extensions(settings: Settings) -> list[CheckResult]: except Exception as error: # noqa: BLE001 — settings fail, doctor continues results.append( CheckResult( - name=f"{extension.name}:settings", + name=f"{app.name}:settings", ok=False, detail=f"check raised: {error}", ) @@ -422,21 +422,21 @@ def check_extensions(settings: Settings) -> list[CheckResult]: else: results.append( CheckResult( - name=f"{extension.name}:settings", + name=f"{app.name}:settings", ok=not problems, detail=detail or "coherent", ) ) - for check in extension.checks or (): - results.append(_run_extension_check(extension.name, check)) + for check in app.checks or (): + results.append(_run_app_check(app.name, check)) return results finally: db_session.remove() engine.dispose() -def _run_extension_check(extension_name: str, check) -> CheckResult: - """One extension check, its result namespaced under the extension. A check that +def _run_app_check(app_name: str, check) -> CheckResult: + """One app check, its result namespaced under the app. A check that raises, or returns anything but a ``CheckResult`` (a missing ``return`` yields ``None``), becomes a failing result rather than escaping and hiding later checks.""" label = getattr(check, "__name__", repr(check)) @@ -445,11 +445,9 @@ def _run_extension_check(extension_name: str, check) -> CheckResult: if not isinstance(outcome, CheckResult): raise TypeError(f"check returned {type(outcome).__name__}, expected CheckResult") except Exception as error: # noqa: BLE001 — the check fails, never aborts - return CheckResult( - name=f"{extension_name}:{label}", ok=False, detail=f"check raised: {error}" - ) + return CheckResult(name=f"{app_name}:{label}", ok=False, detail=f"check raised: {error}") return CheckResult( - name=f"{extension_name}:{outcome.name}", + name=f"{app_name}:{outcome.name}", ok=outcome.ok, detail=outcome.detail, pending=outcome.pending, @@ -466,7 +464,7 @@ def _run_extension_check(extension_name: str, check) -> CheckResult: check_redis, check_drukbox, check_capability_modules, - check_extensions, + check_apps, ) diff --git a/backend/druks/durable/datastructures.py b/backend/druks/durable/datastructures.py index e58ca941..2f64cfbd 100644 --- a/backend/druks/durable/datastructures.py +++ b/backend/druks/durable/datastructures.py @@ -15,7 +15,7 @@ class Subject: Subclass it: the class name is the subject type, so ``PullRequest`` is ``pull_request``, and the id is the whole record — ``owner/repo#7`` is what a run, an event, or a read is keyed by, and what the subject shows itself as. - An extension that also keeps a row of its own subclasses ``StoredSubject`` + An app that also keeps a row of its own subclasses ``StoredSubject`` instead.""" subject_type: ClassVar[str] @@ -45,7 +45,7 @@ def get_for_subject_id(cls, subject_id: str) -> Self | None: return cls(id=subject_id) def get_summary(self) -> "SubjectSummary": - """The header its board and page show it under — the extension's own fields; + """The header its board and page show it under — the app's own fields; the read side composes it with the platform's status and timeline.""" raise NotImplementedError( f"a workflow declares {type(self).__name__}, so it needs a get_summary()" @@ -55,7 +55,7 @@ def get_summary(self) -> "SubjectSummary": def list_summaries(cls, account_id: str | None) -> "Sequence[SubjectSummary]": """The subjects on this class's board, newest-movement first, each as its domain summary. ``account_id`` is the caller, or None outside a request. A shared - board ignores it. Returns a covariant ``Sequence`` so an extension can return + board ignores it. Returns a covariant ``Sequence`` so an app can return a ``list`` of its own ``SubjectSummary`` subclass. Required once a workflow declares it.""" raise NotImplementedError( @@ -84,7 +84,7 @@ async def get_phase(self) -> str | None: @classmethod def list_open(cls, *, limit: int = 50) -> list[Self]: """The subjects of this class whose newest run hasn't handed off — still going, - or failed and wanting the operator. What an extension's active view lists when + or failed and wanting the operator. What an app's active view lists when the subject is identity alone; a subject with rows of its own lists them with ``StoredSubject.list_open``.""" # Cycle: the durable read side is built on this package's models. diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 87ffaba4..736b4d29 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -48,7 +48,7 @@ class Run(Base): # The parked gate's recv topic — which gate, e.g. "review_plan"; presence ⇒ # PARKED. The DBOS routing key, set automatically from the Gate class. input_gate: Mapped[str | None] = mapped_column(default=None) - # The structured ask the gate declared at Gate.wait(input_request=…) — the extension's + # The structured ask the gate declared at Gate.wait(input_request=…) — the app's # opaque payload, surfaced by the read-side and cleared on resume beside input_gate. input_request: Mapped[dict[str, Any] | None] = mapped_column(JSONB, default=None) # When the run last asked for input — a historical fact (input_gate says @@ -185,7 +185,7 @@ def open_subject_ids(cls, subject_type: str) -> Select: @classmethod def get_open_subjects(cls) -> Select: """Every open workflow — the newest run of each kind on each subject, still - going or failed — newest first across every installed extension.""" + going or failed — newest first across every installed app.""" state = state_expression(cls.id, cls.input_gate, cls.created_at).label("state") attributes = workflow_status.c.attributes subject_type = attributes["subject_type"].as_string().label("subject_type") diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index 9e593897..48151063 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -131,7 +131,7 @@ def _running_calls(run: Run | None) -> list[AgentCall]: def _status(driving_run: Run | None, calls: list[AgentCall]) -> SubjectStatus: - # Facts only: the extension's UI renders its copy from them. + # Facts only: the app's UI renders its copy from them. if not driving_run: return SubjectStatus(state=RunState.SCHEDULED) # A parked run is always the active one (ACTIVE_STATES), so the @@ -215,7 +215,7 @@ async def stream_transcript( ) -> AsyncIterator[str]: # Tail an agent call's log from ``offset``, emitting each new slice as a # ``transcript.chunk`` and closing with ``agent_call.finished`` once the call reaches - # a terminal state. The platform owns the call's log, so an extension supplies only the + # a terminal state. The platform owns the call's log, so an app supplies only the # call id — no resolver, no poll loop. Keepalive comments cover idle ticks. Each poll # re-derives the call's status off its run, so a call left unfinished when its run # went terminal reads "abandoned" and closes the stream on the next tick. diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index a872d94b..42b53733 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -138,7 +138,7 @@ def from_run( class SubjectSummary(BaseResponse): - # The base an extension's subject header subclasses; ``id`` keys the subject's + # The base an app's subject header subclasses; ``id`` keys the subject's # status, timeline and detail URL, and ``from_attributes`` builds the header # straight off the subject. model_config = ConfigDict(from_attributes=True) @@ -150,13 +150,13 @@ class SubjectSummary(BaseResponse): class SubjectStatus(BaseResponse): # The subject's lifecycle status for the dashboard lane — derived by the read # side, never stored; ``state`` is the canonical RunState aggregated across - # the subject's runs. Everything else is a fact the extension's UI renders + # the subject's runs. Everything else is a fact the app's UI renders # its own copy from; the platform ships no prose. state: RunState # The driving run's kind and, while running, its latest agent call's agent. kind: str | None = None agent: str | None = None - # A parked run's gate identity — the extension's UI maps it to its own + # A parked run's gate identity — the app's UI maps it to its own # words; the ask's content rides the timeline's ``input_request``. gate: str | None = None # The stop reason of the run driving ``state`` — set only when that run is @@ -196,7 +196,7 @@ class SubjectList(BaseResponse): class SubjectActivity(BaseResponse): # The running sub-phase the timeline can't show ("Building sandbox VM…"), supplied - # by the extension; ``kind`` groups it for display ("infra" | "agent"). + # by the app; ``kind`` groups it for display ("infra" | "agent"). label: str kind: str diff --git a/backend/druks/events/builder.py b/backend/druks/events/builder.py index 8edb338f..8f936156 100644 --- a/backend/druks/events/builder.py +++ b/backend/druks/events/builder.py @@ -10,23 +10,23 @@ def build_feed( *, - extension: str | None = None, + app: str | None = None, before: int | None = None, limit: int = _PAGE_LIMIT_DEFAULT, ) -> tuple[list[FeedItem], str | None]: - items = [FeedItem.model_validate(event) for event in _events(extension, before)] + items = [FeedItem.model_validate(event) for event in _events(app, before)] items.sort(key=lambda item: item.seq, reverse=True) page = items[:limit] next_cursor = str(page[-1].seq) if len(page) == limit and page else None return page, next_cursor -def _events(extension: str | None, before: int | None) -> list[Event]: - # This extension's events plus any unscoped (core) ones. The log stores the extension; +def _events(app: str | None, before: int | None) -> list[Event]: + # This app's events plus any unscoped (core) ones. The log stores the app; # the core never derives it from the subject. stmt = select(Event).order_by(Event.id.desc()) if before: stmt = stmt.where(Event.id < before) - if extension: - stmt = stmt.where(or_(Event.extension == extension, Event.extension.is_(None))) + if app: + stmt = stmt.where(or_(Event.app == app, Event.app.is_(None))) return list(db_session().scalars(stmt.limit(_FETCH_LIMIT)).all()) diff --git a/backend/druks/events/feed.py b/backend/druks/events/feed.py index 0ffd8cda..6d39338a 100644 --- a/backend/druks/events/feed.py +++ b/backend/druks/events/feed.py @@ -13,9 +13,9 @@ class FeedItem(BaseResponse): seq: int = Field(validation_alias="id") at: datetime = Field(validation_alias="created_at") # The event type verbatim: a lifecycle topic ("workflow.finished") or the - # milestone an extension recorded ("merged"). The words are the client's. + # milestone an app recorded ("merged"). The words are the client's. kind: str = Field(validation_alias="type") - extension: str | None = None + app: str | None = None # The durable kind of the workflow a lifecycle row is about ("ship.build"). workflow: str | None = Field(default=None, validation_alias=AliasPath("payload", "kind")) subject_type: str | None = None diff --git a/backend/druks/events/models.py b/backend/druks/events/models.py index 846bd69b..1b90d7b9 100644 --- a/backend/druks/events/models.py +++ b/backend/druks/events/models.py @@ -11,7 +11,7 @@ class Event(Base): """The append-only log: one row per run-state transition and (later) domain - milestone, keyed to the subject it concerns. An extension reads it back as a feed, + milestone, keyed to the subject it concerns. An app reads it back as a feed, or folds the newest-per-subject into a status.""" __tablename__ = "events" @@ -28,7 +28,7 @@ class Event(Base): # How the subject showed itself when the event was written — the feed labels # rows without ever loading a subject. Absent exactly when the subject is. subject_label: Mapped[str | None] = mapped_column(default=None) - extension: Mapped[str | None] = mapped_column(default=None) + app: Mapped[str | None] = mapped_column(default=None) # Append-only, so creation time is the event time. No updated_at. created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict) @@ -41,7 +41,7 @@ def emit( subject: dict[str, Any] | None = None, label: str | None = None, payload: dict[str, Any] | None = None, - extension: str | None = None, + app: str | None = None, ) -> None: subject = subject or {} db_session().add( @@ -50,7 +50,7 @@ def emit( subject_type=subject.get("type"), subject_id=str(subject["id"]) if "id" in subject else None, subject_label=label or None, - extension=extension, + app=app, payload=payload or {}, ) ) diff --git a/backend/druks/events/routes.py b/backend/druks/events/routes.py index 2e64ce80..da3ab944 100644 --- a/backend/druks/events/routes.py +++ b/backend/druks/events/routes.py @@ -35,10 +35,10 @@ def _parse_cursor(raw: str | None) -> int | None: async def list_feed( limit: int = Query(default=200, ge=1, le=500), before: str | None = Query(default=None), - extension: str | None = Query(default=None), + app: str | None = Query(default=None), ) -> FeedResponse: cursor = _parse_cursor(before) - items, next_cursor = build_feed(extension=extension, before=cursor, limit=limit) + items, next_cursor = build_feed(app=app, before=cursor, limit=limit) return FeedResponse(items=items, next_cursor=next_cursor) @@ -46,7 +46,7 @@ async def list_feed( async def stream_feed( request: Request, engine: EngineDep, - extension: str | None = Query(default=None), + app: str | None = Query(default=None), ) -> StreamingResponse: async def feed_stream(): last_seq: int | None = None @@ -58,7 +58,7 @@ async def feed_stream(): # sleep; the open/close cost is irrelevant against the poll cadence. with session_scope(engine): items, _next_cursor = build_feed( - extension=extension, + app=app, before=None, limit=100 if first else 50, ) diff --git a/backend/druks/extensions/__init__.py b/backend/druks/extensions/__init__.py deleted file mode 100644 index 9e59dc0b..00000000 --- a/backend/druks/extensions/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import Extension, ExtensionSettings, Secret - -__all__ = ["Extension", "ExtensionSettings", "Secret"] diff --git a/backend/druks/extensions/exceptions.py b/backend/druks/extensions/exceptions.py deleted file mode 100644 index 22434d02..00000000 --- a/backend/druks/extensions/exceptions.py +++ /dev/null @@ -1,45 +0,0 @@ -class ExtensionConfigError(Exception): - """An extension's ``.druks`` config could not be parsed or validated. - - Raised at intake (and by push-time validation) so bad config fails - loudly where the work starts instead of half-applying.""" - - -class SettingsDeclarationError(Exception): - """A ``Settings`` inner class declares a field the settings plane can't render or - validate — e.g. a nested model. Raised at declaration (extension/workflow subclass - creation) so a bad settings shape fails loudly where it's written, not at the first - operator PATCH.""" - - -class SubscriberDeclarationError(Exception): - """A subscriber's signature asks for a routing key — one a filter matches on but - no body is handed. Raised at declaration, so it fails on import instead of - inside the durable step that publishes the signal.""" - - -class ExtensionLoadError(Exception): - """An extension could not be loaded, app-lessly or at full boot. The concrete - subclass names the failed stage; nothing raises this base directly.""" - - -class ExtensionNotFound(ExtensionLoadError): - """No installed extension declares the requested name under the - ``druks.extensions`` entry-point group — the package isn't installed.""" - - -class MalformedExtension(ExtensionLoadError): - """The extension's entry point resolves to something that isn't an - ``Extension`` subclass, or its metadata target can't be resolved at all — - a packaging mistake, not a runtime error inside the extension.""" - - -class ExtensionImportError(ExtensionLoadError): - """Importing the extension's models or capability modules raised. The - extension is installed and well-declared, but its own code failed on - import — carries the original exception as its cause.""" - - -class ExtensionSubjectContractError(ExtensionLoadError): - """A declared subject fails the read-side contract: no ``list_summaries()`` - implementation, or it names the reserved ``transcripts`` segment.""" diff --git a/backend/druks/extensions/loader.py b/backend/druks/extensions/loader.py deleted file mode 100644 index 692367ae..00000000 --- a/backend/druks/extensions/loader.py +++ /dev/null @@ -1,253 +0,0 @@ -from importlib.metadata import entry_points -from typing import TYPE_CHECKING - -from druks.extensions import Extension - -from .exceptions import ExtensionImportError, ExtensionNotFound, MalformedExtension - -if TYPE_CHECKING: - from importlib.metadata import EntryPoint - from types import ModuleType - - from fastapi import FastAPI - -_GROUP = "druks.extensions" - -# Which extension owns each workflow-declaring package — the loader-validated -# installation claims, stamped once an entry point checks out, so a Workflow -# class resolves its identity at definition time. None marks a package whose -# workflows belong to no extension (how test modules register themselves). -_workflow_packages: dict[str, str | None] = {} - - -def register_workflow_package(package: str, extension: str | None) -> None: - # Conflicting or overlapping claims are a packaging mistake — two installs - # can't share a workflow package. - if package in _workflow_packages: - if _workflow_packages[package] != extension: - raise MalformedExtension( - f"package {package!r} already belongs to " - f"{_workflow_packages[package]!r} — {extension!r} can't claim it" - ) - return - for registered, owner in _workflow_packages.items(): - nested = registered.startswith(f"{package}.") or package.startswith(f"{registered}.") - if nested and owner != extension: - raise MalformedExtension( - f"package {package!r} overlaps {registered!r} (owned by {owner!r}) — " - "workflow ownership must be unambiguous" - ) - _workflow_packages[package] = extension - - -def resolve_workflow_extension(module: str) -> str | None: - """The extension owning ``module``'s nearest registered ancestor package. - Raises ``LookupError`` when no registered package contains the module.""" - prefix = module - while prefix: - if prefix in _workflow_packages: - return _workflow_packages[prefix] - prefix = prefix.rpartition(".")[0] - raise LookupError(module) - - -def iter_extensions() -> list[type[Extension]]: - """Every installed extension, resolved from the ``druks.extensions`` entry points. - Loading an entry point imports the extension's class. An extension that fails to - import, resolves to a non-``Extension``, or collides on ``name`` raises — there - is no per-extension fault tolerance yet (deferred until a real external extension - exists).""" - extensions: list[type[Extension]] = [] - seen: set[str] = set() - for entry in entry_points(group=_GROUP): - extension = entry.load() - if not (isinstance(extension, type) and issubclass(extension, Extension)): - raise TypeError(f"extension entry point {entry.name!r} is not an Extension") - if extension.name != entry.name: - raise MalformedExtension( - f"extension {entry.name!r} entry point resolves to an Extension named " - f"{extension.name!r} — the entry-point name must match Extension.name" - ) - if extension.name in seen: - raise ValueError(f"duplicate extension name {extension.name!r}") - seen.add(extension.name) - # Ownership registers before discover() imports the capability modules, - # whose Workflow classes resolve their extension at definition. - register_workflow_package(extension.package, extension.name) - extensions.append(extension) - return extensions - - -def get_extension(name: str) -> type[Extension]: - for extension in iter_extensions(): - if extension.name == name: - return extension - raise KeyError(f"no installed extension named {name!r}") - - -def load_extension(name: str) -> type[Extension]: - """Load one installed extension without the web app: resolve its entry point, - register its tables, and import its capability modules so its workflows, - routes, subscribers, and webhooks self-register. Returns the ``Extension`` - class, with every surface then enumerable off it (``workflows()``, - ``routers()``, ``capability_modules()``, ``settings_model``, - ``migrations_dir()``). The load path used by the CLI, tests, and evals — - no FastAPI, nothing mounted. - - Fails loudly and by name: an uninstalled package raises ``ExtensionNotFound``; - an entry point that doesn't resolve to an ``Extension`` raises - ``MalformedExtension``; the extension's own code raising on import raises - ``ExtensionImportError``; a declared subject that fails the read-side contract - raises ``ExtensionSubjectContractError``.""" - extension = _resolve(name) - try: - import_extension_models(extension) - extension.discover() - except Exception as error: - raise ExtensionImportError(f"extension {name!r} failed to import: {error}") from error - # Outside the try: a contract break is not an import error. - extension.subjects() - return extension - - -def _resolve(name: str) -> type[Extension]: - """The single installed extension named ``name``, loaded to its ``Extension`` - class. Entry points are listed, not imported, so an unknown name fails before - any extension code runs.""" - matches = [e for e in entry_points(group=_GROUP) if e.name == name] - if not matches: - raise ExtensionNotFound( - f"no installed extension named {name!r} — install its package first" - ) - if len(matches) > 1: - # Two installed distributions register the same entry-point key — the name - # keys the /api, settings, and migration namespaces, so this is a broken - # install. Caught from metadata alone, without importing anything. - raise MalformedExtension( - f"extension {name!r} is declared by {len(matches)} installed packages " - f"({', '.join(e.value for e in matches)}) — uninstall all but one" - ) - extension = _load_entry(matches[0]) - # The entry-point key must equal the class's ``name`` — the key scopes the - # /api, settings, and migration namespaces, which is what lets the - # duplicate-key check above stand in for a duplicate-name check without - # importing sibling extensions. - if extension.name != name: - raise MalformedExtension( - f"extension {name!r} entry point resolves to an Extension named " - f"{extension.name!r} — the entry-point name must match Extension.name" - ) - register_workflow_package(extension.package, extension.name) - return extension - - -def _load_entry(entry: "EntryPoint") -> type[Extension]: - """Resolve one entry point to its ``Extension`` class, distinguishing a - packaging mistake from the extension's own code failing on import. A missing - target module or attribute is ``MalformedExtension`` (bad metadata); the - target module existing but raising on import is ``ExtensionImportError`` (the - extension's code) — the two the caller's taxonomy must tell apart.""" - import importlib - - try: - module = importlib.import_module(entry.module) - except ModuleNotFoundError as error: - if error.name == entry.module or entry.module.startswith(f"{error.name}."): - raise MalformedExtension( - f"entry point {entry.value!r} points at a module that isn't installed: {error}" - ) from error - # A dependency the entry module imports is missing — its code ran and failed. - raise ExtensionImportError( - f"extension entry module {entry.module!r} failed to import: {error}" - ) from error - except Exception as error: - raise ExtensionImportError( - f"extension entry module {entry.module!r} failed to import: {error}" - ) from error - - extension = module - for attribute in filter(None, (entry.attr or "").split(".")): - try: - extension = getattr(extension, attribute) - except AttributeError as error: - raise MalformedExtension( - f"entry point {entry.value!r} names {attribute!r}, which its module doesn't define" - ) from error - if not (isinstance(extension, type) and issubclass(extension, Extension)): - raise MalformedExtension(f"entry point {entry.value!r} is not an Extension") - return extension - - -def _tables_declared_in(package: str) -> set[str]: - # A table belongs to whoever declared its model, not to whoever happened to import - # it first: an extension entry module pulls its own models in, and a sibling's. - from druks.models import Base - - return { - mapper.local_table.name - for mapper in Base.registry.mappers - if mapper.class_.__module__.startswith(f"{package}.") and mapper.local_table is not None - } - - -def import_extension_models(only: type[Extension] | None = None) -> None: - """Import extensions' ``.models`` so their tables register on the shared - metadata before ``create_all`` or autogenerate. Defaults to every installed - extension (boot, migrations); pass ``only`` to register a single extension's tables - for an app-less load. A separately-shipped extension's tables must carry its - ``_`` prefix — the platform scopes its migrations by that prefix, so an - unprefixed table would be invisible to them and fails the load instead. Builtin - extensions are exempt: their schema is core's.""" - import importlib - import importlib.util - - extensions = [only] if only else iter_extensions() - for extension in extensions: - name = f"{extension.package}.models" - if not importlib.util.find_spec(name): - continue - importlib.import_module(name) - misnamed = sorted( - table - for table in _tables_declared_in(extension.package) - if not table.startswith(extension.table_prefix) - ) - if misnamed and extension.prefix_tables and not extension.builtin: - raise ValueError( - f"extension {extension.name!r} tables must start with " - f"{extension.table_prefix!r}: {misnamed}" - ) - - -def mount(app: "FastAPI", extension: type[Extension], modules: list["ModuleType"]) -> None: - """Mount one discovered extension under ``/api/`` and ``/app/``. - The prefix and the identity gate are the loader's — no extension hook can - override them.""" - # Local, matching get_routers: the loader stays importable app-lessly. - from fastapi import Depends - - from druks.accounts.dependencies import current_account - - prefix = f"/api/{extension.name}" - for router in extension.get_routers(modules): - app.include_router( - router, - prefix=prefix, - tags=[extension.name], - dependencies=[Depends(current_account)], - ) - dist = extension.frontend_dist() - if dist: - # /app, not /api: unknown /api/* paths stay JSON 404s. - app.frontend(f"/app/{extension.name}", directory=dist) - - -def load(app: "FastAPI") -> None: - """API boot: for each extension — discover, validate subjects, mount.""" - # The table-prefix check runs here, not just in makemigrations — an author - # who hand-writes migrations still can't boot with an unprefixed table. - import_extension_models() - for extension in iter_extensions(): - modules = extension.discover() - extension.subjects() - mount(app, extension, modules) diff --git a/backend/druks/extensions/routes.py b/backend/druks/extensions/routes.py deleted file mode 100644 index 0306619c..00000000 --- a/backend/druks/extensions/routes.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter - -from .loader import iter_extensions -from .schemas import ExtensionResponse - -router = APIRouter(prefix="/api/extensions", tags=["extensions"]) - - -@router.get("", response_model=list[ExtensionResponse], response_model_by_alias=True) -async def list_extensions() -> list[ExtensionResponse]: - return [ - ExtensionResponse( - name=extension.name, - icon=extension.icon, - description=extension.description, - builtin=extension.builtin, - subject_types=[subject.subject_type for subject in extension.subjects()], - has_frontend=bool(extension.frontend_dist()), - navigation=extension.navigation, - ) - for extension in iter_extensions() - ] diff --git a/backend/druks/harnesses/codex.py b/backend/druks/harnesses/codex.py index 04de5e06..5856238c 100644 --- a/backend/druks/harnesses/codex.py +++ b/backend/druks/harnesses/codex.py @@ -45,7 +45,7 @@ # Codex (ChatGPT subscription) usage endpoint — the standalone fetch the # `codex` CLI's account/rateLimits/read RPC uses for the `chatgpt` auth -# extension. Returns the same numbers /status shows without a completion. +# app. Returns the same numbers /status shows without a completion. _CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" _CODEX_USER_AGENT = "codex-cli" diff --git a/backend/druks/mcp/catalog.py b/backend/druks/mcp/catalog.py index d856fc1e..cb114850 100644 --- a/backend/druks/mcp/catalog.py +++ b/backend/druks/mcp/catalog.py @@ -3,7 +3,7 @@ from pydantic import ValidationError -from druks.extensions.registry import mcp_servers +from druks.apps.registry import mcp_servers from druks.mcp.constants import NAME_PATTERN from druks.mcp.exceptions import InvalidCatalogError from druks.mcp.schemas import CatalogEntry diff --git a/backend/druks/mcp/models.py b/backend/druks/mcp/models.py index e0d26423..fc68e1ef 100644 --- a/backend/druks/mcp/models.py +++ b/backend/druks/mcp/models.py @@ -8,9 +8,9 @@ from sqlalchemy.orm import Mapped, mapped_column from druks.accounts.constants import SYSTEM_ACCOUNT_ID +from druks.apps.registry import mcp_servers from druks.core.models import Uuid7Pk from druks.database import db_session -from druks.extensions.registry import mcp_servers from druks.mcp.constants import NAME_PATTERN from druks.mcp.enums import TokenSource from druks.mcp.exceptions import InvalidServerNameError diff --git a/backend/druks/mcp/routes.py b/backend/druks/mcp/routes.py index 6830356d..083f62eb 100644 --- a/backend/druks/mcp/routes.py +++ b/backend/druks/mcp/routes.py @@ -5,8 +5,8 @@ from fastapi.responses import HTMLResponse from druks.accounts.context import current_account_id +from druks.apps.registry import mcp_servers from druks.core.templates import render_page -from druks.extensions.registry import mcp_servers from druks.mcp import oauth, registry from druks.mcp.enums import IdentityMode, TokenSource from druks.mcp.exceptions import ( diff --git a/backend/druks/mcp/app.py b/backend/druks/mcp/server.py similarity index 89% rename from backend/druks/mcp/app.py rename to backend/druks/mcp/server.py index bac1d9de..9bb37d40 100644 --- a/backend/druks/mcp/app.py +++ b/backend/druks/mcp/server.py @@ -1,7 +1,7 @@ # The inbound /mcp endpoint ("server" stays reserved for the registry rows). # Its tools are derived from the routes tagged "agent": the route is an # operation's single declaration — schema, docstring, operation_id — and a -# tagged extension route joins the surface the same way. +# tagged app route joins the surface the same way. import inspect from collections.abc import Generator @@ -18,8 +18,8 @@ from druks.accounts.exceptions import InvalidPatError from druks.accounts.models import PersonalAccessToken +from druks.apps.loader import iter_apps from druks.database import db_session -from druks.extensions.loader import iter_extensions from druks.mcp.exceptions import InvalidAgentToolError _INSTRUCTIONS = """\ @@ -79,7 +79,7 @@ def _validate_agent_tools(api: FastAPI) -> None: # deferred (api.routes holds unresolved routers), so the contexts iterator # is the one view with every route's merged tags. Validation owns only the # two demands the author owns — an explicit operation_id and a non-empty - # docstring; the extension prefix is the framework's to derive, not the + # docstring; the app prefix is the framework's to derive, not the # author's to repeat (see _namespace_agent_operations). for route in iter_route_contexts(api.routes): if not isinstance(route.original_route, APIRoute) or "agent" not in route.tags: @@ -92,12 +92,12 @@ def _validate_agent_tools(api: FastAPI) -> None: raise InvalidAgentToolError(where, "a non-empty endpoint docstring is required") -def _namespace_agent_operations(spec: dict, extension_names: set[str]) -> None: - # Derive each extension-owned agent operation's id to f"{extension}_{operation_id}", +def _namespace_agent_operations(spec: dict, app_names: set[str]) -> None: + # Derive each app-owned agent operation's id to f"{app}_{operation_id}", # so the tool name the provider reads off the spec is namespaced without the - # author repeating the prefix. The loader tags every extension route with its - # extension's name, so among an agent operation's tags the one naming an - # installed extension is the owner; platform agent operations carry no such + # author repeating the prefix. The loader tags every app route with its + # app's name, so among an agent operation's tags the one naming an + # installed app is the owner; platform agent operations carry no such # tag and keep their declared ids. An already-prefixed id passes through, so # stable names like ship_start never double — and the namespace is what makes # the merged document's operation ids globally unique. A derived id that would @@ -117,9 +117,9 @@ def _namespace_agent_operations(spec: dict, extension_names: set[str]) -> None: operation_id = operation.get("operationId") if not operation_id: continue - extension = next((tag for tag in operation["tags"] if tag in extension_names), None) - if extension and not operation_id.startswith(f"{extension}_"): - derived = f"{extension}_{operation_id}" + app = next((tag for tag in operation["tags"] if tag in app_names), None) + if app and not operation_id.startswith(f"{app}_"): + derived = f"{app}_{operation_id}" if derived in existing_ids: raise InvalidAgentToolError( path, @@ -139,12 +139,12 @@ def _install_agent_namespacing(api: FastAPI) -> None: # which already returns the cached schema when it is warm, so namespaced() # need not repeat that guard — and the derivation is idempotent, so running # it on a warm cache leaves the already-prefixed ids untouched. - extension_names = {extension.name for extension in iter_extensions()} + app_names = {app.name for app in iter_apps()} generate = api.openapi def namespaced() -> dict: spec = generate() - _namespace_agent_operations(spec, extension_names) + _namespace_agent_operations(spec, app_names) return spec api.openapi = namespaced diff --git a/backend/druks/models.py b/backend/druks/models.py index 8b098a86..92921ee7 100644 --- a/backend/druks/models.py +++ b/backend/druks/models.py @@ -41,7 +41,7 @@ def utc_now() -> datetime: class StoredSubject(Base): - """A row an extension's runs are about — a work item, a repo, a document. + """A row an app's runs are about — a work item, a repo, a document. Subclass it instead of ``Base``: the class name is the subject type, so ``WorkItem`` is ``work_item``.""" @@ -87,7 +87,7 @@ def get_for_subject_id(cls, subject_id: str) -> Self | None: return db_session().get(cls, key) def get_summary(self) -> "SubjectSummary": - """The header its board and page show it under — the extension's own fields; + """The header its board and page show it under — the app's own fields; the read side composes it with the platform's status and timeline.""" raise NotImplementedError( f"a workflow declares {type(self).__name__}, so it needs a get_summary()" @@ -97,7 +97,7 @@ def get_summary(self) -> "SubjectSummary": def list_summaries(cls, account_id: str | None) -> "Sequence[SubjectSummary]": """The rows on this class's board, newest-movement first, each as its domain summary. ``account_id`` is the caller, or None outside a request. A shared - board ignores it. Returns a covariant ``Sequence`` so an extension can return + board ignores it. Returns a covariant ``Sequence`` so an app can return a ``list`` of its own ``SubjectSummary`` subclass. Required once a workflow declares it.""" raise NotImplementedError( @@ -122,7 +122,7 @@ async def get_phase(self) -> str | None: @classmethod def list_open(cls, *, limit: int = 50) -> list[Self]: """The rows whose newest run hasn't handed off — still going, or failed - and wanting the operator. What an extension's active view lists.""" + and wanting the operator. What an app's active view lists.""" # Cycle: the durable read side is built on this module's Base. from druks.database import db_session from druks.durable.models import Run diff --git a/backend/druks/prompts/resolver.py b/backend/druks/prompts/resolver.py index 85959a43..3338c041 100644 --- a/backend/druks/prompts/resolver.py +++ b/backend/druks/prompts/resolver.py @@ -5,15 +5,15 @@ from jinja2 import Environment, FileSystemLoader, PrefixLoader, StrictUndefined from jinja2.sandbox import ImmutableSandboxedEnvironment -from druks.extensions.fetcher import fetch_file -from druks.extensions.loader import iter_extensions +from druks.apps.fetcher import fetch_file +from druks.apps.loader import iter_apps @functools.cache def _environment() -> Environment: - # One Jinja environment over every installed extension's own ``templates`` root, - # each mounted under the extension's name: ``ship/build/implement.md`` is - # ``build/implement.md`` inside ship's package, so nothing repeats the extension in + # One Jinja environment over every installed app's own ``templates`` root, + # each mounted under the app's name: ``ship/build/implement.md`` is + # ``build/implement.md`` inside ship's package, so nothing repeats the app in # its own tree. Overrides resolved as strings via ``from_string`` still see the # loader for ``{% include %}`` against partials. # @@ -23,7 +23,7 @@ def _environment() -> Environment: # ``workspace`` objects in context. Bundled templates only read public attributes, # so the sandbox is invisible to them. return ImmutableSandboxedEnvironment( - loader=PrefixLoader(_extension_template_roots()), + loader=PrefixLoader(_app_template_roots()), autoescape=False, undefined=StrictUndefined, keep_trailing_newline=True, @@ -32,15 +32,15 @@ def _environment() -> Environment: ) -def _extension_template_roots() -> dict[str, FileSystemLoader]: +def _app_template_roots() -> dict[str, FileSystemLoader]: roots: dict[str, FileSystemLoader] = {} - for extension in iter_extensions(): - spec = importlib.util.find_spec(extension.package) + for app in iter_apps(): + spec = importlib.util.find_spec(app.package) if not spec or not spec.submodule_search_locations: continue root = Path(spec.submodule_search_locations[0]) / "templates" if root.is_dir(): - roots[extension.name] = FileSystemLoader(root) + roots[app.name] = FileSystemLoader(root) return roots @@ -55,9 +55,9 @@ async def render_prompt( Resolution order (first found wins), always against default branches: - 1. ``/.druks//prompts/`` — repo-specific tuning - 2. ``/.druks`` repo ``/prompts/`` — org-wide tuning - 3. ```` under the extension's own ``/templates`` root — built-in baseline + 1. ``/.druks//prompts/`` — repo-specific tuning + 2. ``/.druks`` repo ``/prompts/`` — org-wide tuning + 3. ```` under the app's own ``/templates`` root — built-in baseline A 404 at a tier silently falls through to the next. Auth or network failures propagate — those are real misconfigurations and the @@ -75,7 +75,7 @@ async def render_prompt( async def _resolve_override(name: str, *, repo: str | None) -> str | None: - namespaced = _extension_prompt_path(name) + namespaced = _app_prompt_path(name) if not repo or not namespaced: return None owner = repo.partition("/")[0] @@ -85,12 +85,12 @@ async def _resolve_override(name: str, *, repo: str | None) -> str | None: return await fetch_file(repo=f"{owner}/.druks", path=namespaced) -def _extension_prompt_path(name: str) -> str | None: +def _app_prompt_path(name: str) -> str | None: """Where a bundled template's repo override lives. Bundled prompts are - namespaced by extension (``/``), and an extension owns ``.druks//``, - so the override is ``/prompts/`` — derived from the name, no table - to keep in sync. A name with no extension segment (no ``/``) isn't overridable.""" - extension, _, rest = name.partition("/") + namespaced by app (``/``), and an app owns ``.druks//``, + so the override is ``/prompts/`` — derived from the name, no table + to keep in sync. A name with no app segment (no ``/``) isn't overridable.""" + app, _, rest = name.partition("/") if not rest: return None - return f"{extension}/prompts/{rest}" + return f"{app}/prompts/{rest}" diff --git a/backend/druks/sandbox/datastructures.py b/backend/druks/sandbox/datastructures.py index a3299d25..bb2206f8 100644 --- a/backend/druks/sandbox/datastructures.py +++ b/backend/druks/sandbox/datastructures.py @@ -23,7 +23,7 @@ class Profile(BaseModel): - """A repo's VM image + env, from ``.druks//config.yml``.""" + """A repo's VM image + env, from ``.druks//config.yml``.""" model_config = {"frozen": True, "extra": "forbid"} @@ -128,7 +128,7 @@ class RequiredMcpServer: @dataclass(frozen=True) class Workspace: - # What an agent runs in: the VM it abstracts. An extension subclasses this and + # What an agent runs in: the VM it abstracts. An app subclasses this and # overrides get_agent_run_kwargs for its project scaffolding (repo token, dirs) # and get_required_mcp_servers for MCP servers it credentials itself. sandbox: "Sandbox" diff --git a/backend/druks/scaffolding/__init__.py b/backend/druks/scaffolding/__init__.py index 92722cbd..f556b7f1 100644 --- a/backend/druks/scaffolding/__init__.py +++ b/backend/druks/scaffolding/__init__.py @@ -1,9 +1,9 @@ from importlib import metadata from pathlib import Path -from druks.extensions.base import NAME_RE +from druks.apps.base import NAME_RE -_TEMPLATE = Path(__file__).parent / "extension_template" +_TEMPLATE = Path(__file__).parent / "app_template" # Django's ``startapp`` trick: template files carry a suffix so nothing in the # template tree is importable or lintable as real code until it's rendered. _TPL_SUFFIX = "-tpl" @@ -11,21 +11,21 @@ _PACKAGE_DIR = "package" -def create_extension(name: str, parent: Path) -> Path: - """Copy the extension template to ``parent/druks-`` and render its +def create_app(name: str, parent: Path) -> Path: + """Copy the app template to ``parent/druks-`` and render its placeholders — a standalone package whose entry point self-registers with the platform on install. Raises ``ValueError`` on a bad name, a collision with an - installed extension, or an existing target directory.""" + installed app, or an existing target directory.""" if not NAME_RE.match(name): raise ValueError( - f"extension name {name!r} must match {NAME_RE.pattern!r} — it keys the " + f"app name {name!r} must match {NAME_RE.pattern!r} — it keys the " "/api/ namespace, the version table, and settings keys" ) - # Names only, no entry.load(): colliding with an installed extension would break + # Names only, no entry.load(): colliding with an installed app would break # boot, and listing entry points doesn't import anything. - installed = {entry.name for entry in metadata.entry_points(group="druks.extensions")} + installed = {entry.name for entry in metadata.entry_points(group="druks.apps")} if name in installed: - raise ValueError(f"extension {name!r} is already installed") + raise ValueError(f"app {name!r} is already installed") target = parent / f"druks-{name}" if target.exists(): raise ValueError(f"{target} already exists") diff --git a/backend/druks/scaffolding/extension_template/AGENTS.md-tpl b/backend/druks/scaffolding/app_template/AGENTS.md-tpl similarity index 72% rename from backend/druks/scaffolding/extension_template/AGENTS.md-tpl rename to backend/druks/scaffolding/app_template/AGENTS.md-tpl index 1cb58149..25b882ba 100644 --- a/backend/druks/scaffolding/extension_template/AGENTS.md-tpl +++ b/backend/druks/scaffolding/app_template/AGENTS.md-tpl @@ -1,32 +1,32 @@ # AGENTS.md -`druks-{{ name }}` is a Druks extension: a standalone Python distribution that owns +`druks-{{ name }}` is a Druks app: a standalone Python distribution that owns domain behavior while Druks supplies durable execution, agents, events, gates, webhooks, and the dashboard. ## Read map -- Extension contracts and the full author surface: - https://github.com/czpython/druks/blob/main/docs/writing-an-extension.md. -- A worked extension to read alongside it: +- App contracts and the full author surface: + https://github.com/czpython/druks/blob/main/docs/writing-an-app.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. ## Contracts - Installing this distribution is the registration. The - `[project.entry-points."druks.extensions"]` key must equal `{{ name }}`. Boot fails + `[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. -- Import the concern namespaces — `druks.extensions`, `druks.workflows`, +- 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. - Domain policy lives here. Generic agent, harness, workspace, sandbox, event, gate, - webhook, and settings plumbing belongs to Druks. When this extension needs + webhook, and settings plumbing belongs to Druks. When this app needs something the author surface lacks, widen the primitive that already owns the concern rather than reaching past it. diff --git a/backend/druks/scaffolding/extension_template/package/__init__.py-tpl b/backend/druks/scaffolding/app_template/package/__init__.py-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/package/__init__.py-tpl rename to backend/druks/scaffolding/app_template/package/__init__.py-tpl diff --git a/backend/druks/scaffolding/extension_template/package/extension.py-tpl b/backend/druks/scaffolding/app_template/package/app.py-tpl similarity index 62% rename from backend/druks/scaffolding/extension_template/package/extension.py-tpl rename to backend/druks/scaffolding/app_template/package/app.py-tpl index 665781f1..ab0733c3 100644 --- a/backend/druks/scaffolding/extension_template/package/extension.py-tpl +++ b/backend/druks/scaffolding/app_template/package/app.py-tpl @@ -1,13 +1,13 @@ -from druks.extensions import Extension +from druks.apps import App -class {{ Name }}(Extension): +class {{ Name }}(App): name = "{{ name }}" # Any Lucide glyph name the frontend bundles ("telescope", "hammer", ...). icon = "box" description = "TODO: one-line blurb shown in the settings pane." - # Operator-tunable knobs go on an inner ``class Settings(ExtensionSettings)`` - # after importing ExtensionSettings from druks.extensions — + # Operator-tunable knobs go on an inner ``class Settings(AppSettings)`` + # after importing AppSettings from druks.apps — # the platform picks up an inner class named ``Settings`` and turns its fields # (default + type + constraints + title/description) into overridable settings. diff --git a/backend/druks/scaffolding/extension_template/package/contracts.py-tpl b/backend/druks/scaffolding/app_template/package/contracts.py-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/package/contracts.py-tpl rename to backend/druks/scaffolding/app_template/package/contracts.py-tpl diff --git a/backend/druks/scaffolding/extension_template/package/dist/entry.js-tpl b/backend/druks/scaffolding/app_template/package/dist/entry.js-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/package/dist/entry.js-tpl rename to backend/druks/scaffolding/app_template/package/dist/entry.js-tpl diff --git a/backend/druks/scaffolding/extension_template/package/migrations/versions/.gitkeep b/backend/druks/scaffolding/app_template/package/migrations/versions/.gitkeep similarity index 100% rename from backend/druks/scaffolding/extension_template/package/migrations/versions/.gitkeep rename to backend/druks/scaffolding/app_template/package/migrations/versions/.gitkeep diff --git a/backend/druks/scaffolding/extension_template/package/models.py-tpl b/backend/druks/scaffolding/app_template/package/models.py-tpl similarity index 72% rename from backend/druks/scaffolding/extension_template/package/models.py-tpl rename to backend/druks/scaffolding/app_template/package/models.py-tpl index f3c633f4..84816a8d 100644 --- a/backend/druks/scaffolding/extension_template/package/models.py-tpl +++ b/backend/druks/scaffolding/app_template/package/models.py-tpl @@ -1,12 +1,12 @@ from druks.db import Base, StoredSubject -# SQLAlchemy models for this extension — subclass ``Base``. Every table name must -# start with "{{ name }}_": the platform scopes this extension's migrations to that +# SQLAlchemy models for this app — subclass ``Base``. Every table name must +# start with "{{ name }}_": the platform scopes this app's migrations to that # prefix (its own history in ``alembic_version_{{ name }}``) and enforces it at boot. # One of these models is usually the thing your runs work on — a note, a repo, a # ticket. Subclass ``StoredSubject`` for that one and point a workflow at it with # ``subject = ThatModel``, and druks shows it a board and a page. Something you keep no # row for subclasses ``druks.workflows.Subject`` instead — same board, same page. # A declared subject must implement ``list_summaries()`` — the board reads it. -# Druks checks this at load and refuses the extension if it is missing. +# Druks checks this at load and refuses the app if it is missing. # After adding a model: ``druks makemigrations {{ name }} -m "..." && druks init-db``. diff --git a/backend/druks/scaffolding/extension_template/package/routes.py-tpl b/backend/druks/scaffolding/app_template/package/routes.py-tpl similarity index 70% rename from backend/druks/scaffolding/extension_template/package/routes.py-tpl rename to backend/druks/scaffolding/app_template/package/routes.py-tpl index 9ba7b12c..dd986e2a 100644 --- a/backend/druks/scaffolding/extension_template/package/routes.py-tpl +++ b/backend/druks/scaffolding/app_template/package/routes.py-tpl @@ -1,11 +1,11 @@ from fastapi import APIRouter # Every APIRouter declared in a ``routes`` module mounts under /api/{{ name }}, -# tagged with the extension's name — declare only the prefix your own resource +# tagged with the app's name — declare only the prefix your own resource # is called. For DB access in a handler use ``from druks.db import db_session``. router = APIRouter() @router.get("/status") async def get_status() -> dict[str, str]: - return {"extension": "{{ name }}"} + return {"app": "{{ name }}"} diff --git a/backend/druks/scaffolding/extension_template/package/schemas.py-tpl b/backend/druks/scaffolding/app_template/package/schemas.py-tpl similarity index 66% rename from backend/druks/scaffolding/extension_template/package/schemas.py-tpl rename to backend/druks/scaffolding/app_template/package/schemas.py-tpl index c9600d01..3f8c3a03 100644 --- a/backend/druks/scaffolding/extension_template/package/schemas.py-tpl +++ b/backend/druks/scaffolding/app_template/package/schemas.py-tpl @@ -1,5 +1,5 @@ from druks.schemas import BaseResponse -# HTTP request/response models for this extension's routes. Response models subclass +# HTTP request/response models for this app's routes. Response models subclass # ``BaseResponse`` (snake_case fields serialize to camelCase for the frontend); # request bodies are plain ``pydantic.BaseModel``. diff --git a/backend/druks/scaffolding/extension_template/package/subscribers.py-tpl b/backend/druks/scaffolding/app_template/package/subscribers.py-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/package/subscribers.py-tpl rename to backend/druks/scaffolding/app_template/package/subscribers.py-tpl diff --git a/backend/druks/scaffolding/extension_template/package/workflows.py-tpl b/backend/druks/scaffolding/app_template/package/workflows.py-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/package/workflows.py-tpl rename to backend/druks/scaffolding/app_template/package/workflows.py-tpl diff --git a/backend/druks/scaffolding/extension_template/pyproject.toml-tpl b/backend/druks/scaffolding/app_template/pyproject.toml-tpl similarity index 65% rename from backend/druks/scaffolding/extension_template/pyproject.toml-tpl rename to backend/druks/scaffolding/app_template/pyproject.toml-tpl index eb8d5635..fe0c2734 100644 --- a/backend/druks/scaffolding/extension_template/pyproject.toml-tpl +++ b/backend/druks/scaffolding/app_template/pyproject.toml-tpl @@ -1,7 +1,7 @@ [project] name = "druks-{{ name }}" version = "0.1.0" -description = "{{ Name }} extension for druks." +description = "{{ Name }} app for druks." requires-python = ">=3.11" dependencies = ["druks"] @@ -11,10 +11,10 @@ dev = [ "pytest-asyncio>=1.3.0", ] -# The platform discovers this extension here — installing the package is the +# The platform discovers this app here — installing the package is the # whole registration. -[project.entry-points."druks.extensions"] -{{ name }} = "druks_{{ name }}.extension:{{ Name }}" +[project.entry-points."druks.apps"] +{{ name }} = "druks_{{ name }}.app:{{ Name }}" [build-system] requires = ["hatchling"] diff --git a/backend/druks/scaffolding/extension_template/tests/test_extension.py-tpl b/backend/druks/scaffolding/app_template/tests/test_app.py-tpl similarity index 100% rename from backend/druks/scaffolding/extension_template/tests/test_extension.py-tpl rename to backend/druks/scaffolding/app_template/tests/test_app.py-tpl diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index da5935b1..4ce613df 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -3,10 +3,10 @@ from pydantic import BaseModel, ValidationError -from druks.extensions.base import NAME_RE -from druks.extensions.loader import iter_extensions -from druks.extensions.registry import services -from druks.extensions.settings import field_kind, field_multiline +from druks.apps.base import NAME_RE +from druks.apps.loader import iter_apps +from druks.apps.registry import services +from druks.apps.settings import field_kind, field_multiline from .exceptions import ServiceConnectError, ServiceNotConnectedError from .models import OauthConnection, ServiceIdentity @@ -17,7 +17,7 @@ class Connection: - """One signed-in provider account, reached through the extension's + """One signed-in provider account, reached through the app's declared handle. ``get_access_token`` and ``disconnect`` act on this sign-in only.""" @@ -55,7 +55,7 @@ async def disconnect(self) -> None: class ScopedService: - """A service seen through one extension's declared scopes + """A service seen through one app's declared scopes (``gmail = Gmail.with_scopes("gmail.readonly")``). The declaration feeds the consent union; the handle reads the connections that grant it.""" @@ -93,7 +93,7 @@ class Service: ``Gmail.get().secrets["client_secret"]``. Used as a class, never instantiated — the same install-singleton shape as - ``Extension``. + ``App``. """ # Keys the service_identities row and the connect wire. Druks derives it @@ -110,7 +110,7 @@ class Service: # Set both endpoints when the registered app is an OAuth client; # ``get_oauth_client()`` then hands back the connected identity as a # configured ``OauthClient``. Scopes are not declared here — the - # extensions that use the service declare them (``with_scopes``), and + # apps that use the service declare them (``with_scopes``), and # the connect door asks for their union. authorization_endpoint: ClassVar[str] = "" token_endpoint: ClassVar[str] = "" @@ -203,7 +203,7 @@ def get(cls) -> ServiceIdentity: @classmethod def with_scopes(cls, *scopes: str) -> ScopedService: - """Declare this extension's use of the service and the scopes its + """Declare this app's use of the service and the scopes its calls need.""" if not cls.token_endpoint: raise TypeError(f"{cls.__name__} declares no OAuth endpoints") @@ -213,8 +213,8 @@ def with_scopes(cls, *scopes: str) -> ScopedService: def declarations(cls) -> "list[ScopedService]": return [ value - for extension in iter_extensions() - for value in vars(extension).values() + for app in iter_apps() + for value in vars(app).values() if isinstance(value, ScopedService) and value.service is cls ] diff --git a/backend/druks/services/routes.py b/backend/druks/services/routes.py index 6c11cf7d..c560e0db 100644 --- a/backend/druks/services/routes.py +++ b/backend/druks/services/routes.py @@ -3,8 +3,8 @@ from druks.accounts.context import current_account_id from druks.accounts.dependencies import current_session_account +from druks.apps.registry import services from druks.core.templates import render_page -from druks.extensions.registry import services from druks.services.exceptions import ( OauthExchangeError, OauthPageError, diff --git a/backend/druks/signals.py b/backend/druks/signals.py index 75bea5f5..adfa0db4 100644 --- a/backend/druks/signals.py +++ b/backend/druks/signals.py @@ -4,7 +4,7 @@ from blinker import signal -from druks.extensions.exceptions import SubscriberDeclarationError +from druks.apps.exceptions import SubscriberDeclarationError __all__ = ["subscribe"] diff --git a/backend/druks/testing.py b/backend/druks/testing.py index 892d5143..3dbc93d5 100644 --- a/backend/druks/testing.py +++ b/backend/druks/testing.py @@ -24,18 +24,18 @@ import druks.services.models # noqa: F401 import druks.skills.models # noqa: F401 import druks.user_settings.models # noqa: F401 +from druks.apps.loader import import_app_models, iter_apps from druks.bootstrap import seed from druks.database import _session_factory, configure_session, create_engine_from_url, db_session from druks.durable import AgentCall, Run from druks.durable.datastructures import Subject from druks.durable.dbos_state import DBOS_SYSTEM_SCHEMA, workflow_status from druks.durable.engine import _dbos_database_url, configure_engine -from druks.extensions.loader import import_extension_models, iter_extensions from druks.models import Base, StoredSubject from druks.settings import Settings from druks.workflows import Workflow, WorkflowError, _bind_instance, current_workflow -# druks.testing is the author door for testing an extension: these four, plus the +# druks.testing is the author door for testing an app: these four, plus the # fixtures the pytest11 entry point registers. Everything else here is the # repository suite's own harness. __all__ = [ @@ -54,9 +54,9 @@ ) TEST_REDIS_URL = os.environ.get("DRUKS_TEST_REDIS_URL", "redis://127.0.0.1:6379/15") -# Discovery runs once for the session, and a broken extension is an ordinary state to +# Discovery runs once for the session, and a broken app is an ordinary state to # be in mid-edit. Holding the failure here lets a suite that never asks for a druks -# fixture finish, and gives one that does an error naming the extension. +# fixture finish, and gives one that does an error naming the app. _discovery_error: Exception | None = None @@ -82,10 +82,10 @@ def pytest_configure(config) -> None: # App commits become savepoints inside the fixture's outer transaction, which # remains available for rollback at test teardown. _session_factory.configure(join_transaction_mode="create_savepoint") - # A Workflow class resolves its declaring extension at definition time, so + # A Workflow class resolves its declaring app at definition time, so # ownership must be claimed before collection imports a module that defines one. try: - iter_extensions() + iter_apps() except Exception as error: # noqa: BLE001 — re-raised from the fixtures below _discovery_error = error @@ -100,9 +100,9 @@ def _druks_engine() -> Iterator[Engine]: def init_db(engine: Engine) -> None: - """Create every installed extension's tables and the first-start seed rows. + """Create every installed app's tables and the first-start seed rows. Idempotent; production owns its schema through Alembic instead.""" - import_extension_models() + import_app_models() # citext backs the case-insensitive email columns and must exist before create_all. with engine.begin() as connection: connection.execute(text("CREATE EXTENSION IF NOT EXISTS citext")) @@ -177,7 +177,7 @@ def configure_app_for_test( authenticated: bool = True, ): from druks.accounts.dependencies import current_account, current_session_account - from druks.api.app import app + from druks.api.server import app if not engine: engine = db_session().get_bind() @@ -257,13 +257,13 @@ async def _cancel(workflow_id: str) -> None: @pytest.fixture def druks_without_remote_config(monkeypatch) -> None: """Every ``.druks`` namespace lookup misses, so prompts resolve to their bundled - templates and extension config to its declared defaults — no repo credentials.""" + templates and app config to its declared defaults — no repo credentials.""" async def _missing(**_kwargs): return None monkeypatch.setattr("druks.prompts.resolver.fetch_file", _missing) - monkeypatch.setattr("druks.extensions.config.fetch_file", _missing) + monkeypatch.setattr("druks.apps.config.fetch_file", _missing) async def run_workflow( diff --git a/backend/druks/usage/extension.py b/backend/druks/usage/app.py similarity index 68% rename from backend/druks/usage/extension.py rename to backend/druks/usage/app.py index 54baff0f..c146c9cf 100644 --- a/backend/druks/usage/extension.py +++ b/backend/druks/usage/app.py @@ -1,7 +1,7 @@ -from druks.extensions import Extension +from druks.apps import App -class Usage(Extension): +class Usage(App): name = "usage" icon = "gauge" description = "Harness usage metering — quota and spend per account." diff --git a/backend/druks/user_settings/models.py b/backend/druks/user_settings/models.py index ad73248e..84d0133b 100644 --- a/backend/druks/user_settings/models.py +++ b/backend/druks/user_settings/models.py @@ -223,17 +223,15 @@ def set_workflow_setting(cls, kind: str, field: str, value: Any) -> None: cls.write(f"workflow:{kind}:{field}", value) @classmethod - def extension_setting(cls, extension: str, field: str, default: Any, *, is_secret: bool) -> Any: - row = db_session().get(cls, f"extension:{extension}:{field}") + def app_setting(cls, app: str, field: str, default: Any, *, is_secret: bool) -> Any: + row = db_session().get(cls, f"app:{app}:{field}") if is_secret: return row.secret_value.decrypt() if row and row.secret_value else default return row.value if row else default @classmethod - def set_extension_setting( - cls, extension: str, field: str, value: Any, *, is_secret: bool - ) -> None: - key = f"extension:{extension}:{field}" + def set_app_setting(cls, app: str, field: str, value: Any, *, is_secret: bool) -> None: + key = f"app:{app}:{field}" if value is None or not is_secret: cls.write(key, value) return diff --git a/backend/druks/user_settings/reads.py b/backend/druks/user_settings/reads.py index db59703d..9d041ff8 100644 --- a/backend/druks/user_settings/reads.py +++ b/backend/druks/user_settings/reads.py @@ -4,21 +4,21 @@ from pydantic.fields import FieldInfo +from druks.apps.settings import field_kind from druks.database import db_session -from druks.extensions.settings import field_kind from druks.harnesses.registry import get_harness_for_model from .models import SettingsOverride from .schemas import ( AgentSettingResponse, - ExtensionSettingsResponse, + AppSettingsResponse, SettingsFieldResponse, WorkflowSettingsResponse, ) if TYPE_CHECKING: from druks.agents import Agent - from druks.extensions import Extension + from druks.apps import App from druks.workflows import Workflow @@ -60,7 +60,7 @@ def get_workflow_settings(workflow: "type[Workflow]") -> WorkflowSettingsRespons ] if workflow.every: # The schedule pair renders like any declared field. The label carries - # the workflow's name since an extension's fields show as one flat list. + # the workflow's name since an app's fields show as one flat list. label = kind.rsplit(".", 1)[-1].replace("_", " ") fields += [ SettingsFieldResponse( @@ -97,32 +97,32 @@ def get_workflow_settings(workflow: "type[Workflow]") -> WorkflowSettingsRespons return WorkflowSettingsResponse(kind=kind, fields=fields) -def get_extension_settings(extension: "type[Extension]") -> ExtensionSettingsResponse: - model = extension.settings_model - return ExtensionSettingsResponse( - name=extension.name, - description=extension.description, - icon=extension.icon, - builtin=extension.builtin, - agents=[get_agent_setting(agent) for agent in extension.agents()], +def get_app_settings(app: "type[App]") -> AppSettingsResponse: + model = app.settings_model + return AppSettingsResponse( + name=app.name, + description=app.description, + icon=app.icon, + builtin=app.builtin, + agents=[get_agent_setting(agent) for agent in app.agents()], # Surface only the workflows with operator knobs: tunable settings or a # schedule to retune. workflows=[ get_workflow_settings(workflow) - for workflow in extension.workflows() + for workflow in app.workflows() if workflow.Settings.model_fields or workflow.every ], settings=[ get_settings_field( name, field, - value=SettingsOverride.extension_setting( - extension.name, + value=SettingsOverride.app_setting( + app.name, name, field.default, is_secret=field_kind(field) == "secret", ), - override_key=f"extension:{extension.name}:{name}", + override_key=f"app:{app.name}:{name}", ) for name, field in (model.model_fields if model else {}).items() ], diff --git a/backend/druks/user_settings/routes.py b/backend/druks/user_settings/routes.py index 3f1fe41c..7442915b 100644 --- a/backend/druks/user_settings/routes.py +++ b/backend/druks/user_settings/routes.py @@ -4,9 +4,9 @@ from druks.accounts.dependencies import current_account, current_session_account from druks.accounts.models import Account +from druks.apps.loader import get_app, iter_apps +from druks.apps.registry import workflows from druks.durable.engine import apply_schedules -from druks.extensions.loader import get_extension, iter_extensions -from druks.extensions.registry import workflows from druks.harnesses.exceptions import HarnessError from druks.harnesses.models import HarnessConnection from druks.harnesses.registry import get_harness_for_model, get_harnesses @@ -16,8 +16,8 @@ from .datastructures import ALLOWED_EFFORTS from .models import HarnessSettings, SettingsOverride, UserSettings from .schemas import ( - ExtensionsSettingsResponse, - ExtensionsSettingsUpdate, + AppsSettingsResponse, + AppsSettingsUpdate, HarnessResponse, HarnessUpdate, UpdateUserSettingsRequest, @@ -110,12 +110,12 @@ async def update_user_settings( return row -@router.get("/extensions", response_model=ExtensionsSettingsResponse, response_model_by_alias=True) -async def get_extension_settings() -> ExtensionsSettingsResponse: - projected = (reads.get_extension_settings(m) for m in iter_extensions()) - return ExtensionsSettingsResponse( +@router.get("/apps", response_model=AppsSettingsResponse, response_model_by_alias=True) +async def get_app_settings() -> AppsSettingsResponse: + projected = (reads.get_app_settings(m) for m in iter_apps()) + return AppsSettingsResponse( allowed_efforts=list(ALLOWED_EFFORTS), - extensions=[out for out in projected if out.agents or out.workflows or out.settings], + apps=[out for out in projected if out.agents or out.workflows or out.settings], ) @@ -150,12 +150,12 @@ def _validate_timeout(value: int | None) -> None: @router.patch( - "/extensions", - response_model=ExtensionsSettingsResponse, + "/apps", + response_model=AppsSettingsResponse, response_model_by_alias=True, dependencies=[Depends(current_session_account)], ) -async def update_extension_settings(body: ExtensionsSettingsUpdate) -> ExtensionsSettingsResponse: +async def update_app_settings(body: AppsSettingsUpdate) -> AppsSettingsResponse: for name, model in body.agent_models.items(): _validate_model(model) SettingsOverride.set_agent_model(name, model) @@ -168,7 +168,7 @@ async def update_extension_settings(body: ExtensionsSettingsUpdate) -> Extension _validate_timeout(timeout) SettingsOverride.set_agent_timeout(name, timeout) - changed_extensions = [] + changed_apps = [] try: for kind, changes in body.workflow_settings.items(): workflow = workflows.get(kind) @@ -176,16 +176,14 @@ async def update_extension_settings(body: ExtensionsSettingsUpdate) -> Extension raise HTTPException(status_code=422, detail=f"Unknown workflow {kind!r}") for field, value in changes.items(): workflow.override_setting(field, value) - for extension_name, changes in body.extension_settings.items(): + for app_name, changes in body.app_settings.items(): try: - extension = get_extension(extension_name) + app = get_app(app_name) except KeyError as exc: - raise HTTPException( - status_code=422, detail=f"Unknown extension {extension_name!r}" - ) from exc + raise HTTPException(status_code=422, detail=f"Unknown app {app_name!r}") from exc for field, value in changes.items(): - extension.override_setting(field, value) - changed_extensions.append(extension) + app.override_setting(field, value) + changed_apps.append(app) except ValueError as exc: # Domain rejections (unknown field, bad cron, failed constraint) → 422. # override_setting has already redacted any submitted value out of the @@ -193,9 +191,9 @@ async def update_extension_settings(body: ExtensionsSettingsUpdate) -> Extension raise HTTPException(status_code=422, detail=str(exc)) from exc settings_problems = {} - for extension in changed_extensions: - if problems := extension.settings().clean(): - settings_problems[extension.name] = problems + for app in changed_apps: + if problems := app.settings().clean(): + settings_problems[app.name] = problems if settings_problems: raise HTTPException(status_code=422, detail=settings_problems) @@ -208,4 +206,4 @@ async def update_extension_settings(body: ExtensionsSettingsUpdate) -> Extension # the just-written overrides off this request's session. apply_schedules() - return await get_extension_settings() + return await get_app_settings() diff --git a/backend/druks/user_settings/schemas.py b/backend/druks/user_settings/schemas.py index 3dbd829b..50b63068 100644 --- a/backend/druks/user_settings/schemas.py +++ b/backend/druks/user_settings/schemas.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field from pydantic.fields import FieldInfo -from druks.extensions.settings import ( +from druks.apps.settings import ( field_choices, field_kind, field_multiline, @@ -158,27 +158,27 @@ class WorkflowSettingsResponse(BaseResponse): fields: list[SettingsFieldResponse] -class ExtensionSettingsResponse(BaseResponse): +class AppSettingsResponse(BaseResponse): name: str description: str # A Lucide icon name the frontend renders (falls back to a default if unknown). icon: str - # Built-in (platform-core) extensions' agents are shown under the Druks tab, not + # Built-in (platform-core) apps' agents are shown under the Druks tab, not # a tab of their own. builtin: bool agents: list[AgentSettingResponse] workflows: list[WorkflowSettingsResponse] - # The extension's own declared settings (not tied to a workflow). Rendered + # The app's own declared settings (not tied to a workflow). Rendered # in the same options section as workflow ones. settings: list[SettingsFieldResponse] -class ExtensionsSettingsResponse(BaseResponse): +class AppsSettingsResponse(BaseResponse): allowed_efforts: list[str] - extensions: list[ExtensionSettingsResponse] + apps: list[AppSettingsResponse] -class ExtensionsSettingsUpdate(BaseModel): +class AppsSettingsUpdate(BaseModel): # agent name -> model (null clears, i.e. inherit the family default). agent_models: dict[str, str | None] = Field( default_factory=dict, @@ -199,8 +199,8 @@ class ExtensionsSettingsUpdate(BaseModel): default_factory=dict, validation_alias="workflowSettings", ) - # extension name -> {field -> value} (null clears). - extension_settings: dict[str, dict[str, Any]] = Field( + # app name -> {field -> value} (null clears). + app_settings: dict[str, dict[str, Any]] = Field( default_factory=dict, - validation_alias="extensionSettings", + validation_alias="appSettings", ) diff --git a/backend/druks/webhooks/base.py b/backend/druks/webhooks/base.py index c2866e04..34f4dd28 100644 --- a/backend/druks/webhooks/base.py +++ b/backend/druks/webhooks/base.py @@ -6,7 +6,7 @@ from fastapi.responses import Response from starlette.routing import compile_path -from druks.extensions.registry import webhooks +from druks.apps.registry import webhooks from druks.settings import Settings from .deliveries import mark_delivery, release_delivery diff --git a/backend/druks/webhooks/router.py b/backend/druks/webhooks/router.py index 64457a5b..ebda7614 100644 --- a/backend/druks/webhooks/router.py +++ b/backend/druks/webhooks/router.py @@ -4,7 +4,7 @@ from fastapi.responses import Response from druks.api.dependencies import SettingsDep -from druks.extensions.registry import webhooks +from druks.apps.registry import webhooks from .exceptions import InvalidWebhookError diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index c8421d3e..d1a46fa6 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -18,6 +18,13 @@ from uuid_utils import uuid7 from druks.accounts.context import current_account_id +from druks.apps.loader import resolve_workflow_app +from druks.apps.registry import workflows +from druks.apps.settings import ( + coerce_setting_value, + validate_setting_override, + validate_settings_declaration, +) from druks.durable.activity import set_run_phase from druks.durable.datastructures import Subject from druks.durable.engine import _step_engine, register_schedule, run_queue, step_session @@ -32,13 +39,6 @@ SubjectSummary, ) from druks.events.models import Event -from druks.extensions.loader import resolve_workflow_extension -from druks.extensions.registry import workflows -from druks.extensions.settings import ( - coerce_setting_value, - validate_setting_override, - validate_settings_declaration, -) from druks.harnesses.exceptions import HarnessError from druks.models import StoredSubject, snake_name from druks.notifications.outbox import notifications_queue, send_notification @@ -77,7 +77,7 @@ # A human gate can park for days; a long recv TTL still caps zombie parks. GATE_TTL_SECONDS = 14 * 24 * 60 * 60 -# The decision verbs an in-app review offers. Framework-owned: an extension's +# The decision verbs an in-app review offers. Framework-owned: an app's # agent supplies the plan and questions (content), but the verbs are ours — so a # resume can only carry an action we defined, the line the resume endpoint checks. _ReviewAction = Literal["approve", "request_changes"] @@ -85,7 +85,7 @@ T = TypeVar("T") -# The running workflow instance, so a Gate's on_wait() can reach its extension's +# The running workflow instance, so a Gate's on_wait() can reach its app's # side-effects (set draft, request review, …) when the gate parks. No default: # Gate.wait() only runs inside a workflow, so it's always set there. current_workflow: ContextVar["Workflow"] = ContextVar("current_workflow") @@ -272,7 +272,7 @@ async def wait( # run-level state — the read surfaces "needs you" straight off the parked run. # ``input_request`` is the plain-dict ask (at least a ``label`` and # ``presentation``), stored on the run beside ``input_gate`` and cleared on - # resume — so an extension declares the ask here, beside on_wait, not at read time. + # resume — so an app declares the ask here, beside on_wait, not at read time. workflow = current_workflow.get() if not workflow._subject and cls.on_wait.__func__ is Gate.on_wait.__func__: # No subject means no feed surface; if on_wait wasn't overridden @@ -451,7 +451,7 @@ def _log_run_event( subject=subject, label=run.subject_label, payload=payload, - extension=workflows.get(run.kind).extension, + app=workflows.get(run.kind).app, ) return payload @@ -514,10 +514,10 @@ async def record_failed(exc: BaseException, code: str) -> None: class Workflow: kind: ClassVar[str] = "" - # The extension that declares this workflow — class identity, resolved from + # The app that declares this workflow — class identity, resolved from # the loader's package registrations at definition time and namespacing # ``kind``. Never supplied or stored per run. - extension: ClassVar[str | None] = None + app: ClassVar[str | None] = None # What this workflow's runs are about, written ``subject = WorkItem`` on the # subclass. None for a workflow about nothing, which says so by silence. subject = _DeclaredSubject(None) @@ -528,9 +528,9 @@ class Workflow: # True holds one warm VM across the run's agent calls (released at gate parks); # False gives each call a throwaway VM. steps_reuse_sandbox: ClassVar[bool] = False - # The Workspace subclass agents run in; an extension sets it (default: the bare VM). + # The Workspace subclass agents run in; an app sets it (default: the bare VM). workspace_class: ClassVar[type[Workspace]] = Workspace - # The Journal subclass the run keeps; an extension sets it for named projections. + # The Journal subclass the run keeps; an app sets it for named projections. journal_class: ClassVar[type[Journal]] = Journal # Exactly one: run() is a single operation, auto-stepped, no ceremony. # run_multistep() orchestrates explicit @step calls and/or gates. @@ -552,21 +552,21 @@ class Settings(BaseModel): def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) try: - cls.extension = resolve_workflow_extension(cls.__module__) + cls.app = resolve_workflow_app(cls.__module__) except LookupError: raise WorkflowError( f"{cls.__module__} declares workflow {cls.__name__} outside every " - "registered extension package — workflow modules load through " - "druks.extensions.loader; a module the loader doesn't own must " + "registered app package — workflow modules load through " + "druks.apps.loader; a module the loader doesn't own must " "register_workflow_package() before importing" ) from None local_kind = cls.__dict__.get("kind") or snake_name(cls.__name__) if "." in local_kind: raise WorkflowError( f"{cls.__name__}.kind {local_kind!r} must be a local name — " - "the declaring extension supplies the namespace" + "the declaring app supplies the namespace" ) - cls.kind = f"{cls.extension}.{local_kind}" if cls.extension else local_kind + cls.kind = f"{cls.app}.{local_kind}" if cls.app else local_kind _declare_subject(cls) validate_settings_declaration(cls.Settings) cls._body_method = _resolve_body_method(cls) @@ -614,7 +614,7 @@ def __init__(self) -> None: self._host: Sandbox | None = None async def announce(self, topic: str, **facts: Any) -> None: - # The workflow announcing a domain event in its extension's vocabulary + # The workflow announcing a domain event in its app's vocabulary # ("pr.opened", pr_number=12, branch="agent/eng-8"). The platform injects # the routing subscribers filter on, and the publish runs as its own # retrying checkpoint so a recovery replay doesn't re-fire it. Body-only, @@ -656,18 +656,18 @@ async def review( async def get_prompt_context(self, **context: Any) -> dict[str, Any]: # Everything an agent's template renders with, beyond the workflow and - # workspace it always gets. Extend via super() to add an extension's standing + # workspace it always gets. Extend via super() to add an app's standing # values (expensive derived prose); the call's own kwargs win on collision. return dict(context) async def get_workspace_kwargs(self, sandbox: "Sandbox") -> dict[str, Any]: - # Extend via super() to add the fields workspace_class needs (an extension clones + mints + # Extend via super() to add the fields workspace_class needs (an app clones + mints # here). Base: just the VM. return {"sandbox": sandbox} async def get_workspace(self, sandbox: "Sandbox") -> Workspace: # What an agent runs in on this run's VM, built per agent call from workspace_class - # + the extension's kwargs — so short-lived tokens (git) mint fresh each call. + # + the app's kwargs — so short-lived tokens (git) mint fresh each call. return self.workspace_class(**await self.get_workspace_kwargs(sandbox)) async def _ensure_host(self) -> str | None: @@ -715,7 +715,7 @@ def has_enabled_schedule(cls) -> bool: @classmethod def settings(cls) -> BaseModel: """The workflow's ``Settings``, resolved through the override store — the read - twin of ``override_setting``, like ``Extension.settings()`` for an extension.""" + twin of ``override_setting``, like ``App.settings()`` for an app.""" values = { name: SettingsOverride.workflow_setting(cls.kind, name, field.default) for name, field in cls.Settings.model_fields.items() diff --git a/backend/migrations/versions/f1d8c6a2b947_apps_are_canonical.py b/backend/migrations/versions/f1d8c6a2b947_apps_are_canonical.py new file mode 100644 index 00000000..cfe0294b --- /dev/null +++ b/backend/migrations/versions/f1d8c6a2b947_apps_are_canonical.py @@ -0,0 +1,33 @@ +"""Use app identity surfaces. + +Revision ID: f1d8c6a2b947 +Revises: b6d4f2a81c93 +Create Date: 2026-08-23 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "f1d8c6a2b947" +down_revision: str | Sequence[str] | None = "b6d4f2a81c93" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE events RENAME COLUMN extension TO app") + op.execute( + "UPDATE settings_overrides " + "SET key = 'app:' || substr(key, 11) " + "WHERE key LIKE 'extension:%'" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE settings_overrides " + "SET key = 'extension:' || substr(key, 5) " + "WHERE key LIKE 'app:%'" + ) + op.execute("ALTER TABLE events RENAME COLUMN app TO extension") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1bd611c..669c28cb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,20 +1,20 @@ from unittest import mock import pytest -from druks.database import create_engine_from_url -from druks.durable.dbos_state import DBOS_SYSTEM_SCHEMA -from druks.extensions.loader import ( - iter_extensions, +from druks.apps.loader import ( + iter_apps, register_workflow_package, ) +from druks.database import create_engine_from_url +from druks.durable.dbos_state import DBOS_SYSTEM_SCHEMA from druks.models import Base from druks.testing import TEST_DATABASE_URL -# A Workflow class resolves its declaring extension at definition time, from +# A Workflow class resolves its declaring app at definition time, from # packages the loader registers before importing. Tests import workflow modules # directly and some declare their own workflows, so both register here — before # collection imports any test module. -iter_extensions() +iter_apps() for test_module in ("test_durable_sdk", "test_notifications_durable"): register_workflow_package(test_module, None) @@ -82,7 +82,7 @@ def registry_state(): # Catalog loads and test registrations mutate the process-global MCP # registry; snapshot and restore so a test's entries don't leak into the # rest of the suite. - from druks.extensions.registry import mcp_servers + from druks.apps.registry import mcp_servers saved = dict(mcp_servers._items) yield @@ -95,7 +95,7 @@ def browser_session_declarations(): # BrowserSession declarations self-register at class definition, so what a # test module defines at import time would leak into every merged-list # read; tests declare inside this fixture and leave the registry as found. - from druks.extensions.registry import browser_sessions + from druks.apps.registry import browser_sessions saved = dict(browser_sessions._items) yield browser_sessions @@ -114,9 +114,9 @@ def browser_session_declarations(): "test_durable_sdk", "test_notifications_durable", "test_harness_login_persistence", - "test_extension_migrations", + "test_app_migrations", "test_plan_gate_migration", - "test_proof_extension_migration", + "test_proof_app_migration", } @@ -134,7 +134,7 @@ def _platform_database(request): def _reset_app_overrides(): yield - from druks.api.app import app + from druks.api.server import app app.dependency_overrides.clear() @@ -142,8 +142,8 @@ def _reset_app_overrides(): @pytest.fixture(autouse=True) def _no_druks_namespace_fetches(monkeypatch): """Default every ``.druks`` namespace fetch to 404. ``render_prompt`` - with a ``repo`` (and extension-config resolution) would otherwise hit - GitHub, which needs Extension creds tests don't have — prompts fall back to + with a ``repo`` (and app-config resolution) would otherwise hit + GitHub, which needs App creds tests don't have — prompts fall back to bundled templates, configs to their model defaults. Tests that exercise the override/config path patch ``fetch_file`` themselves.""" @@ -151,7 +151,7 @@ async def _none(**_kwargs): return None monkeypatch.setattr("druks.prompts.resolver.fetch_file", _none) - monkeypatch.setattr("druks.extensions.config.fetch_file", _none) + monkeypatch.setattr("druks.apps.config.fetch_file", _none) def bind_ambient_session(session) -> None: @@ -215,8 +215,8 @@ def finish_agent_run(call, *, status=None, last_error=None): def make_test_note(body: str = "a note"): - """The platform suite's subject. It belongs to the proof extension, not to ship — - platform behavior must hold for any extension's rows.""" + """The platform suite's subject. It belongs to the proof app, not to ship — + platform behavior must hold for any app's rows.""" from druks_field_notes.models import Note return Note.create(body=body) diff --git a/backend/tests/druks-field_notes/druks_field_notes/extension.py b/backend/tests/druks-field_notes/druks_field_notes/app.py similarity index 89% rename from backend/tests/druks-field_notes/druks_field_notes/extension.py rename to backend/tests/druks-field_notes/druks_field_notes/app.py index cbd787a6..aed5fc00 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/extension.py +++ b/backend/tests/druks-field_notes/druks_field_notes/app.py @@ -2,14 +2,14 @@ from typing import Literal from druks.agents import Agent +from druks.apps import App, AppSettings, Secret from druks.doctor import CheckResult -from druks.extensions import Extension, ExtensionSettings, Secret from pydantic import Field, SecretStr, field_validator from druks_field_notes.contracts import GistOutput # The env var field_notes would read its summarizer credential from. Unset in a -# bare install, so the check below reports it missing — the "extension owns a check +# bare install, so the check below reports it missing — the "app owns a check # for its own API key" case, kept to an env read so the proof package needs no real # provider. API_KEY_ENV = "FIELD_NOTES_API_KEY" @@ -27,13 +27,13 @@ def check_summary_api_key() -> CheckResult: return CheckResult(name="summary_api_key", ok=True, detail="set") -class FieldNotes(Extension): +class FieldNotes(App): name = "field_notes" icon = "notebook" description = "Turns a jotted observation into a one-line gist with an agent." navigation = [("/field_notes", "notes")] - class Settings(ExtensionSettings): + class Settings(AppSettings): # How many recent notes the board shows — an operator knob, so it lives here. board_size: int = Field( default=50, @@ -49,7 +49,7 @@ class Settings(ExtensionSettings): title="Visibility", description="Who the field-notes board is shared with.", ) - # A secret: the key the extension would use to reach an outside notes service. + # A secret: the key the app would use to reach an outside notes service. # SecretStr, so its value is redacted everywhere it surfaces; empty means # unset, and a malformed key is rejected server-side (with its raw value # kept out of the error). @@ -88,7 +88,7 @@ def clean(self) -> dict[str, str]: return {"sync_token": "Required when visibility is public."} return {} - # The one agent this extension runs: it reads a note and writes its gist. + # The one agent this app runs: it reads a note and writes its gist. summarize = Agent( description="reads a note and writes its one-line gist", prompt="field_notes/summarize.md", @@ -96,6 +96,6 @@ def clean(self) -> dict[str, str]: model="claude", ) - # The extension's own precondition, reported by `druks doctor` beside the + # The app's own precondition, reported by `druks doctor` beside the # platform's: the summarizer's API key must be set. checks = [check_summary_api_key] diff --git a/backend/tests/druks-field_notes/druks_field_notes/migrations/versions/field_notes_0001_notes.py b/backend/tests/druks-field_notes/druks_field_notes/migrations/versions/field_notes_0001_notes.py index c52e7fd0..6b1d66b0 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/migrations/versions/field_notes_0001_notes.py +++ b/backend/tests/druks-field_notes/druks_field_notes/migrations/versions/field_notes_0001_notes.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from alembic import op -# The history root for this extension's own alembic_version_field_notes table — +# The history root for this app's own alembic_version_field_notes table — # down_revision is None, and it never links to core's revisions. revision = "field_notes_0001" down_revision = None diff --git a/backend/tests/druks-field_notes/druks_field_notes/models.py b/backend/tests/druks-field_notes/druks_field_notes/models.py index 2e4cdd90..e8423812 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/models.py +++ b/backend/tests/druks-field_notes/druks_field_notes/models.py @@ -43,8 +43,8 @@ def get_summary(self) -> NoteSummary: @classmethod def list_summaries(cls, account_id: str | None) -> list[NoteSummary]: - # How many the board shows is an operator knob, so it lives on the extension. - from druks_field_notes.extension import FieldNotes + # How many the board shows is an operator knob, so it lives on the app. + from druks_field_notes.app import FieldNotes notes = cls.list_recent(limit=FieldNotes.settings().board_size) return [note.get_summary() for note in notes] diff --git a/backend/tests/druks-field_notes/druks_field_notes/subscribers.py b/backend/tests/druks-field_notes/druks_field_notes/subscribers.py index 9d9afb98..0af60c33 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/subscribers.py +++ b/backend/tests/druks-field_notes/druks_field_notes/subscribers.py @@ -1,7 +1,7 @@ from druks.signals import subscribe from druks.workflows import WorkflowEvent -from druks_field_notes.extension import FieldNotes +from druks_field_notes.app import FieldNotes from druks_field_notes.models import Note from druks_field_notes.workflows import Summarize @@ -9,5 +9,5 @@ @subscribe(WorkflowEvent.FINISHED, workflow=Summarize) async def note_summarized(*, subject: Note, **_: object) -> None: # A finished summarize is a milestone worth its own feed row. The workflow - # lifecycle is the trigger; the extension only reacts. + # lifecycle is the trigger; the app only reacts. FieldNotes.record_event(type="summarized", subject=subject) diff --git a/backend/tests/druks-field_notes/druks_field_notes/workflows.py b/backend/tests/druks-field_notes/druks_field_notes/workflows.py index 40ede54f..9086fa45 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/workflows.py +++ b/backend/tests/druks-field_notes/druks_field_notes/workflows.py @@ -1,6 +1,6 @@ from druks.workflows import Workflow -from druks_field_notes.extension import FieldNotes +from druks_field_notes.app import FieldNotes from druks_field_notes.models import Note @@ -13,7 +13,7 @@ class Summarize(Workflow): async def run(self) -> None: note = self.subject # The note body is the agent's prompt context; the gist it returns is the - # extension's own domain result, saved onto the note. + # app's own domain result, saved onto the note. result = await FieldNotes.summarize(note_body=note.body) note.save_gist(result.gist) diff --git a/backend/tests/druks-field_notes/pyproject.toml b/backend/tests/druks-field_notes/pyproject.toml index 0a177aae..6fd524de 100644 --- a/backend/tests/druks-field_notes/pyproject.toml +++ b/backend/tests/druks-field_notes/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "druks-field_notes" version = "0.1.0" -description = "Field notes — the out-of-tree proof extension for druks." +description = "Field notes — the out-of-tree proof app for druks." requires-python = ">=3.11" dependencies = ["druks"] @@ -9,10 +9,10 @@ dependencies = ["druks"] [tool.uv.sources] druks = { path = "../../..", editable = true } -# The platform discovers this extension here — installing the package is the +# The platform discovers this app here — installing the package is the # whole registration. -[project.entry-points."druks.extensions"] -field_notes = "druks_field_notes.extension:FieldNotes" +[project.entry-points."druks.apps"] +field_notes = "druks_field_notes.app:FieldNotes" [build-system] requires = ["hatchling"] diff --git a/backend/tests/druks-field_notes/tests/test_extension.py b/backend/tests/druks-field_notes/tests/test_app.py similarity index 93% rename from backend/tests/druks-field_notes/tests/test_extension.py rename to backend/tests/druks-field_notes/tests/test_app.py index 708c8a14..b046a64c 100644 --- a/backend/tests/druks-field_notes/tests/test_extension.py +++ b/backend/tests/druks-field_notes/tests/test_app.py @@ -1,5 +1,5 @@ import pytest -from druks_field_notes.extension import FieldNotes +from druks_field_notes.app import FieldNotes from druks_field_notes.models import Note from pydantic import ValidationError @@ -36,7 +36,7 @@ def test_settings_require_a_sync_token_for_public_visibility(): def test_the_signing_key_declares_the_multiline_secret_presentation(): # An author marks a pasted PEM-shaped secret multiline; the platform keeps # newlines intact end to end, so the stored value is exactly the paste. - from druks.extensions.settings import field_kind, field_multiline + from druks.apps.settings import field_kind, field_multiline field = FieldNotes.Settings.model_fields["sync_signing_key"] assert field_kind(field) == "secret" diff --git a/backend/tests/druks-field_notes/tests/test_workflows.py b/backend/tests/druks-field_notes/tests/test_workflows.py index 3bb86175..22cce517 100644 --- a/backend/tests/druks-field_notes/tests/test_workflows.py +++ b/backend/tests/druks-field_notes/tests/test_workflows.py @@ -1,8 +1,8 @@ from unittest import mock from druks.testing import run_workflow +from druks_field_notes.app import FieldNotes from druks_field_notes.contracts import GistOutput -from druks_field_notes.extension import FieldNotes from druks_field_notes.models import Note from druks_field_notes.workflows import Summarize diff --git a/backend/tests/ship/test_api_work_items.py b/backend/tests/ship/test_api_work_items.py index 66ca303d..631ca6a3 100644 --- a/backend/tests/ship/test_api_work_items.py +++ b/backend/tests/ship/test_api_work_items.py @@ -244,7 +244,7 @@ def test_timeline_shows_every_build_attempt(druks_db): async def test_subject_activity_surfaces_running_phase(druks_db, monkeypatch): # A running build run pushes a transient phase; the detail view's live activity # surfaces it ("Building sandbox VM…") — finer than the lifecycle status. - from druks.contrib.ship import extension as ship_extension + from druks.contrib.ship import app as ship_app item = make_test_work_item(repo="ClawHaven/acme-app", title="x") seed_build_run(druks_db, work_item_id=item.id, state="running") @@ -253,7 +253,7 @@ async def phase(_run_id): return "provisioning_vm" monkeypatch.setattr("druks.durable.reads.get_run_phase", phase) - activity = await ship_extension.Ship.get_subject_activity(item) + activity = await ship_app.Ship.get_subject_activity(item) assert activity is not None assert activity.label == "Building sandbox VM…" assert activity.kind == "infra" @@ -261,9 +261,9 @@ async def phase(_run_id): async def test_subject_activity_none_when_not_running(druks_db): # A run parked on a gate isn't working — no live sub-phase. - from druks.contrib.ship import extension as ship_extension + from druks.contrib.ship import app as ship_app item = make_test_work_item(repo="ClawHaven/acme-app", title="x") seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate="review_plan") - assert await ship_extension.Ship.get_subject_activity(item) is None + assert await ship_app.Ship.get_subject_activity(item) is None diff --git a/backend/tests/ship/test_extension_config.py b/backend/tests/ship/test_app_config.py similarity index 88% rename from backend/tests/ship/test_extension_config.py rename to backend/tests/ship/test_app_config.py index ef9bb9ac..65673e2a 100644 --- a/backend/tests/ship/test_extension_config.py +++ b/backend/tests/ship/test_app_config.py @@ -1,9 +1,9 @@ from types import SimpleNamespace import pytest +from druks.apps.config import resolve_app_config +from druks.apps.exceptions import AppConfigError from druks.contrib.ship.policy import RepoPolicy -from druks.extensions.config import resolve_extension_config -from druks.extensions.exceptions import ExtensionConfigError from druks.testing import make_settings REPO = "acme/widget" @@ -19,11 +19,11 @@ async def fetch(*, repo: str, path: str) -> str | None: calls.append((repo, path)) return files.get((repo, path)) - monkeypatch.setattr("druks.extensions.config.fetch_file", fetch) + monkeypatch.setattr("druks.apps.config.fetch_file", fetch) return calls -class TestResolveExtensionConfig: +class TestResolveAppConfig: async def test_only_the_repo_tier_is_fetched(self, monkeypatch): """No org-wide .druks tier — one repo, one file, one fetch.""" calls = _fake_fetch( @@ -35,9 +35,9 @@ async def test_only_the_repo_tier_is_fetched(self, monkeypatch): assert calls == [(REPO, ".druks/ship/config.yml")] async def test_unknown_key_fails_loudly(self, monkeypatch): - """A typo'd key is a ExtensionConfigError at resolution, not a silent no-op.""" + """A typo'd key is a AppConfigError at resolution, not a silent no-op.""" _fake_fetch(monkeypatch, {(REPO, ".druks/ship/config.yml"): "verficiation: {}\n"}) - with pytest.raises(ExtensionConfigError, match="invalid ship config"): + with pytest.raises(AppConfigError, match="invalid ship config"): await RepoPolicy.resolve(REPO) async def test_no_file_yields_defaults(self, monkeypatch): @@ -47,7 +47,7 @@ async def test_no_file_yields_defaults(self, monkeypatch): async def test_repo_none_skips_fetching_entirely(self, monkeypatch): calls = _fake_fetch(monkeypatch, {}) - await resolve_extension_config("ship", repo=None, model=RepoPolicy) + await resolve_app_config("ship", repo=None, model=RepoPolicy) assert calls == [] @@ -56,11 +56,11 @@ class TestFetchFile: def _wire(self, monkeypatch, tmp_path, github): settings = make_settings(tmp_path) - monkeypatch.setattr("druks.extensions.fetcher.load_settings", lambda: settings) - monkeypatch.setattr("druks.extensions.fetcher.get_github_client", lambda: github) + monkeypatch.setattr("druks.apps.fetcher.load_settings", lambda: settings) + monkeypatch.setattr("druks.apps.fetcher.get_github_client", lambda: github) async def test_404_is_cached_as_empty(self, monkeypatch, tmp_path): - from druks.extensions.fetcher import fetch_file + from druks.apps.fetcher import fetch_file fetches = [] @@ -78,7 +78,7 @@ async def aclose(self): assert len(fetches) == 1 async def test_fetch_error_propagates(self, monkeypatch, tmp_path): - from druks.extensions.fetcher import fetch_file + from druks.apps.fetcher import fetch_file class _GitHub: async def get_file_content(self, repo, path): @@ -92,12 +92,12 @@ async def aclose(self): await fetch_file(repo=REPO, path=".druks/ship/config.yml") -class TestExtensionPromptOverridePaths: +class TestAppPromptOverridePaths: """Unlike Ship's config.yml, prompt overrides keep the org-then-repo tiering.""" - async def test_repo_extension_dir_checked_before_org(self, monkeypatch): + async def test_repo_app_dir_checked_before_org(self, monkeypatch): """``ship/build/*`` resolves via the repo's - ``.druks/ship/prompts/...`` first, then the org's extension dir.""" + ``.druks/ship/prompts/...`` first, then the org's app dir.""" from druks.prompts.resolver import _resolve_override calls: list[tuple[str, str]] = [] @@ -114,7 +114,7 @@ async def fetch(*, repo: str, path: str) -> str | None: (ORG_DRUKS, "ship/prompts/build/implement.md"), ] - async def test_repo_extension_dir_wins(self, monkeypatch): + async def test_repo_app_dir_wins(self, monkeypatch): from druks.prompts.resolver import _resolve_override async def fetch(*, repo: str, path: str) -> str | None: @@ -220,7 +220,7 @@ async def test_legacy_none_plan_gate_fails_loudly(self, monkeypatch): monkeypatch, {(REPO, ".druks/ship/config.yml"): "gates: {plan_approval: none}\n"}, ) - with pytest.raises(ExtensionConfigError, match="invalid ship config"): + with pytest.raises(AppConfigError, match="invalid ship config"): await RepoPolicy.resolve(REPO) async def test_absent_verification_key_means_no_pin(self, monkeypatch): @@ -233,7 +233,7 @@ async def test_unknown_gate_value_fails_loudly(self, monkeypatch): monkeypatch, {(REPO, ".druks/ship/config.yml"): "gates: {implementation_approval: agent}\n"}, ) - with pytest.raises(ExtensionConfigError, match="invalid ship config"): + with pytest.raises(AppConfigError, match="invalid ship config"): await RepoPolicy.resolve(REPO) async def test_gates_default_to_inherit(self, monkeypatch): diff --git a/backend/tests/ship/test_build_dispatch.py b/backend/tests/ship/test_build_dispatch.py index e709d178..fddc81b6 100644 --- a/backend/tests/ship/test_build_dispatch.py +++ b/backend/tests/ship/test_build_dispatch.py @@ -82,7 +82,7 @@ async def test_the_tracker_funnel_swallows_the_missing_identity(druks_db, monkey ticket transition into an escaping exception (which the webhook layer would 5xx into indefinite redelivery).""" from druks.contrib.ship import subscribers # noqa: F401 — the import registers it - from druks.contrib.ship.extension import Ship + from druks.contrib.ship.app import Ship settings = Ship.settings() item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-9", source=settings.tracker) diff --git a/backend/tests/ship/test_build_plan_phase.py b/backend/tests/ship/test_build_plan_phase.py index 5cb726e3..16d5d014 100644 --- a/backend/tests/ship/test_build_plan_phase.py +++ b/backend/tests/ship/test_build_plan_phase.py @@ -1,6 +1,7 @@ from types import SimpleNamespace import pytest +from druks.contrib.ship.app import Ship from druks.contrib.ship.contracts import ( AcceptanceCriterionOutput, PlanData, @@ -10,7 +11,6 @@ ReviewWork, ) from druks.contrib.ship.enums import ReviewDecision -from druks.contrib.ship.extension import Ship from druks.contrib.ship.policy import PlanGate, RepoPolicy from druks.contrib.ship.workflows import Build from druks.workflows import FatalError, OperatorReply, Run diff --git a/backend/tests/ship/test_build_workspace.py b/backend/tests/ship/test_build_workspace.py index 6a22d405..eec72fee 100644 --- a/backend/tests/ship/test_build_workspace.py +++ b/backend/tests/ship/test_build_workspace.py @@ -108,11 +108,11 @@ async def _review_token(_repo: str) -> str: workflow = Build() workflow.input = Build._run_input_model() - workflow.subject = SimpleNamespace(repo="o/extension") + workflow.subject = SimpleNamespace(repo="o/app") workflow._profile = {"recommended_skills": ["python-house-rules"]} kwargs = await workflow.get_workspace_kwargs(sandbox) - assert ensured == ["https://github.com/o/extension"] + assert ensured == ["https://github.com/o/app"] assert ["mkdir", "-p", get_related_root("exedev")] in execs assert kwargs["mcp_token"] == "ghs_review" assert kwargs["skills"] == ("python-house-rules",) @@ -138,7 +138,7 @@ async def _no_token(_repo: str) -> str: workflow = Build() workflow.input = Build._run_input_model() - workflow.subject = SimpleNamespace(repo="o/extension") + workflow.subject = SimpleNamespace(repo="o/app") workflow._profile = {"recommended_skills": ["python-house-rules"]} with pytest.raises(FatalError, match="github MCP server"): diff --git a/backend/tests/ship/test_profiling.py b/backend/tests/ship/test_profiling.py index 6ac82262..ae485741 100644 --- a/backend/tests/ship/test_profiling.py +++ b/backend/tests/ship/test_profiling.py @@ -1,5 +1,5 @@ import pytest -from druks.contrib.ship.extension import Ship +from druks.contrib.ship.app import Ship from druks.contrib.ship.models import Project, ProjectRepo from druks.contrib.ship.policy import RepoPolicy, VerificationProfile from druks.contrib.ship.workflows import Profile diff --git a/backend/tests/ship/test_ticketing.py b/backend/tests/ship/test_ticketing.py index 22b23101..9efdc4ed 100644 --- a/backend/tests/ship/test_ticketing.py +++ b/backend/tests/ship/test_ticketing.py @@ -2,7 +2,7 @@ import httpx import pytest -from druks.contrib.ship.extension import Ship, check_tracker_identity +from druks.contrib.ship.app import Ship, check_tracker_identity from druks.contrib.ship.ticketing.enums import TicketStatus from druks.contrib.ship.ticketing.jira import Jira from druks.contrib.ship.ticketing.linear import Linear diff --git a/backend/tests/ship/test_webhooks_pull_request.py b/backend/tests/ship/test_webhooks_pull_request.py index ff506022..7fa34d13 100644 --- a/backend/tests/ship/test_webhooks_pull_request.py +++ b/backend/tests/ship/test_webhooks_pull_request.py @@ -23,7 +23,7 @@ def _stub_config_fetch(monkeypatch): async def _fetch(*, repo, path): return None - monkeypatch.setattr("druks.extensions.config.fetch_file", _fetch) + monkeypatch.setattr("druks.apps.config.fetch_file", _fetch) def _milestone_count(work_item_id, milestone): @@ -284,7 +284,7 @@ async def test_external_close_honors_delete_branch_policy(druks_db, tmp_path, mo async def _fetch(*, repo, path): return "delete_branch: false\n" - monkeypatch.setattr("druks.extensions.config.fetch_file", _fetch) + monkeypatch.setattr("druks.apps.config.fetch_file", _fetch) deleted = [] diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index d563c166..e00ce7bc 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -3,9 +3,9 @@ import pytest from druks.accounts.models import Account, PersonalAccessToken -from druks.api.app import app +from druks.api.server import app from druks.contrib.review.workflows import PullRequestReview -from druks.contrib.ship.extension import Ship +from druks.contrib.ship.app import Ship from druks.contrib.ship.models import Project, ProjectRepo, WorkItem from druks.contrib.ship.ticketing.enums import TicketStatus from druks.contrib.ship.workflows import Build @@ -88,7 +88,7 @@ def _park(druks_db, note, *, context: str = ""): return run -def test_openapi_pins_platform_and_extension_agent_routes(client: TestClient): +def test_openapi_pins_platform_and_app_agent_routes(client: TestClient): schema = app.openapi() found = { (method, path): operation @@ -100,11 +100,11 @@ def test_openapi_pins_platform_and_extension_agent_routes(client: TestClient): assert found[("post", "/api/review/reviews")]["operationId"] == "review_request" assert found[("post", "/api/ship/work-items/{ticket}/start")]["operationId"] == "ship_start" - extensions = { + apps = { key: {name: value for name, value in found[key].items() if name.startswith("x-")} for key in _MCP_ROUTES } - assert extensions == { + assert apps == { ("get", "/api/gates/{run}"): {}, ("post", "/api/gates/{run}/answer"): { "x-destructive": False, @@ -374,7 +374,7 @@ def test_list_open_subjects_returns_newest_open_work_and_latest_calls(client: Te "subjectLabel": subject_label, "workflows": [ { - "extension": "field_notes", + "app": "field_notes", "state": "failed", "run": newest.id, "latestAgentCall": latest_call.id, diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 502d02ce..c1193aa3 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -127,7 +127,7 @@ async def test_run_outside_workflow_raises(): async def test_run_refuses_an_agent_with_no_id(): - # Built loose — never assigned to an Extension, never given an explicit id — so + # Built loose — never assigned to an App, never given an explicit id — so # the call it would record would name nobody. loose = agents.Agent(prompt="dummy/agent.md", contract=DummyOutput, model="claude") with pytest.raises(WorkflowError, match="runs under its own id"): diff --git a/backend/tests/test_api_settings.py b/backend/tests/test_api_settings.py index db767490..90da27bd 100644 --- a/backend/tests/test_api_settings.py +++ b/backend/tests/test_api_settings.py @@ -1,7 +1,7 @@ from pathlib import Path -from druks.contrib.review.extension import Review -from druks.contrib.ship.extension import Ship +from druks.contrib.review.app import Review +from druks.contrib.ship.app import Ship from druks.database import db_session from druks.testing import configure_app_for_test, make_settings from druks.user_settings.models import SettingsOverride @@ -193,39 +193,39 @@ def test_patch_unknown_harness_is_404(tmp_path: Path): assert response.status_code == 404 -def _ship_extension(client: TestClient) -> dict: - body = client.get("/api/settings/extensions").json() - return next(m for m in body["extensions"] if m["name"] == "ship") +def _ship_app(client: TestClient) -> dict: + body = client.get("/api/settings/apps").json() + return next(m for m in body["apps"] if m["name"] == "ship") def _ship_settings_fields(client: TestClient) -> dict: - return {field["name"]: field for field in _ship_extension(client)["settings"]} + return {field["name"]: field for field in _ship_app(client)["settings"]} -def _review_extension(client: TestClient) -> dict: - body = client.get("/api/settings/extensions").json() - return next(m for m in body["extensions"] if m["name"] == "review") +def _review_app(client: TestClient) -> dict: + body = client.get("/api/settings/apps").json() + return next(m for m in body["apps"] if m["name"] == "review") def _review_settings_fields(client: TestClient) -> dict: - return {field["name"]: field for field in _review_extension(client)["settings"]} + return {field["name"]: field for field in _review_app(client)["settings"]} -def test_extensions_surface_build_agents(tmp_path: Path): - """The build pipeline's agents all tune under the Ship extension.""" +def test_apps_surface_build_agents(tmp_path: Path): + """The build pipeline's agents all tune under the Ship app.""" with _build_client(tmp_path) as client: - body = client.get("/api/settings/extensions").json() - extensions = {m["name"]: m for m in body["extensions"]} + body = client.get("/api/settings/apps").json() + apps = {m["name"]: m for m in body["apps"]} - build_agents = {a["name"]: a for a in extensions["ship"]["agents"]} + build_agents = {a["name"]: a for a in apps["ship"]["agents"]} # The build pipeline's plan stage stays; the standalone Plan-tab agent is gone. assert "generate_plan" in build_agents assert "planning" not in build_agents -def test_extensions_surface_build_agents_and_workflow_defaults(tmp_path: Path): +def test_apps_surface_build_agents_and_workflow_defaults(tmp_path: Path): with _build_client(tmp_path) as client: - build = _ship_extension(client) + build = _ship_app(client) agents = {a["name"]: a for a in build["agents"]} # An agent's family-token default resolves to the family's model; effort @@ -274,15 +274,15 @@ def test_extensions_surface_build_agents_and_workflow_defaults(tmp_path: Path): } -def test_extension_secret_round_trip_encrypts_at_rest(tmp_path: Path): +def test_app_secret_round_trip_encrypts_at_rest(tmp_path: Path): secret = "review-pem-value" app_id = "42424242" - key = "extension:review:private_key" + key = "app:review:private_key" with _build_client(tmp_path) as client: written = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={ - "extensionSettings": { + "appSettings": { "review": { "app_id": app_id, "private_key": secret, @@ -301,7 +301,7 @@ def test_extension_secret_round_trip_encrypts_at_rest(tmp_path: Path): ) .one() ) - read = client.get("/api/settings/extensions") + read = client.get("/api/settings/apps") resolved = Review.settings().private_key assert written.status_code == 200 @@ -315,9 +315,7 @@ def test_extension_secret_round_trip_encrypts_at_rest(tmp_path: Path): assert app_id not in written.text assert app_id not in read.text assert resolved and resolved.get_secret_value() == secret - review = next( - extension for extension in read.json()["extensions"] if extension["name"] == "review" - ) + review = next(app for app in read.json()["apps"] if app["name"] == "review") fields = {field["name"]: field for field in review["settings"]} assert fields["private_key"]["type"] == "secret" assert fields["private_key"]["value"] is None @@ -327,19 +325,19 @@ def test_extension_secret_round_trip_encrypts_at_rest(tmp_path: Path): assert fields["app_id"]["secretSet"] is True -def test_extension_secret_plaintext_row_is_unset_until_resaved(tmp_path: Path): +def test_app_secret_plaintext_row_is_unset_until_resaved(tmp_path: Path): secret = "legacy-plaintext-secret" - key = "extension:review:private_key" + key = "app:review:private_key" db_session().add(SettingsOverride(key=key, value=secret)) db_session().flush() with _build_client(tmp_path) as client: - initial = _review_extension(client) + initial = _review_app(client) resolved_initial = Review.settings().private_key saved = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={ - "extensionSettings": { + "appSettings": { "review": { "app_id": "42", "private_key": secret, @@ -371,14 +369,14 @@ def test_extension_secret_plaintext_row_is_unset_until_resaved(tmp_path: Path): assert secret.encode() not in stored.secret_value -def test_extension_non_secret_setting_stays_in_value(tmp_path: Path): +def test_app_non_secret_setting_stays_in_value(tmp_path: Path): status = "Agent Queue" - key = "extension:ship:linear_trigger_status" + key = "app:ship:linear_trigger_status" with _build_client(tmp_path) as client: written = client.patch( - "/api/settings/extensions", - json={"extensionSettings": {"ship": {"linear_trigger_status": status}}}, + "/api/settings/apps", + json={"appSettings": {"ship": {"linear_trigger_status": status}}}, ) stored = ( db_session() @@ -388,7 +386,7 @@ def test_extension_non_secret_setting_stays_in_value(tmp_path: Path): ) .one() ) - ship = _ship_extension(client) + ship = _ship_app(client) field = next( setting for setting in ship["settings"] if setting["name"] == "linear_trigger_status" @@ -401,7 +399,7 @@ def test_extension_non_secret_setting_stays_in_value(tmp_path: Path): assert field["overridden"] is True -def test_incoherent_extension_save_is_rejected_and_rolled_back_before_schedules( +def test_incoherent_app_save_is_rejected_and_rolled_back_before_schedules( tmp_path: Path, monkeypatch ): reconciled = [] @@ -410,11 +408,11 @@ def test_incoherent_extension_save_is_rejected_and_rolled_back_before_schedules( ) with _build_client(tmp_path) as client: response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={ "agentModels": {"generate_plan": "claude-opus-4-7"}, "workflowSettings": {"core.refresh_tokens": {"schedule": "0 9 * * *"}}, - "extensionSettings": {"review": {"app_id": "42"}}, + "appSettings": {"review": {"app_id": "42"}}, }, ) @@ -424,19 +422,19 @@ def test_incoherent_extension_save_is_rejected_and_rolled_back_before_schedules( } assert not reconciled assert _review_settings_fields(client)["app_id"]["secretSet"] is False - agents = {agent["name"]: agent for agent in _ship_extension(client)["agents"]} + agents = {agent["name"]: agent for agent in _ship_app(client)["agents"]} assert agents["generate_plan"]["model"] == "gpt-5.5" assert _refresh_tokens_fields(client)["schedule"]["value"] == "*/15 * * * *" def test_clearing_the_identity_deletes_its_overrides_and_stays_coherent(tmp_path: Path): - key = "extension:review:app_id" + key = "app:review:app_id" with _build_client(tmp_path) as client: configured = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={ - "extensionSettings": { + "appSettings": { "review": { "app_id": "42", "private_key": "review-pem", @@ -445,8 +443,8 @@ def test_clearing_the_identity_deletes_its_overrides_and_stays_coherent(tmp_path }, ) cleared = client.patch( - "/api/settings/extensions", - json={"extensionSettings": {"review": {"app_id": None, "private_key": None}}}, + "/api/settings/apps", + json={"appSettings": {"review": {"app_id": None, "private_key": None}}}, ) stored = ( db_session() @@ -466,30 +464,30 @@ def test_clearing_the_identity_deletes_its_overrides_and_stays_coherent(tmp_path assert fields["private_key"]["secretSet"] is False -def test_extensions_override_agent_model_persists(tmp_path: Path): +def test_apps_override_agent_model_persists(tmp_path: Path): with _build_client(tmp_path) as client: patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"agentModels": {"implement": "gpt-5.5"}}, ) assert patch.status_code == 200 - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_app(client)["agents"]} assert agents["implement"]["model"] == "gpt-5.5" assert agents["implement"]["source"] == "agent" -def test_extensions_harness_effort_and_per_agent_effort_override(tmp_path: Path): +def test_apps_harness_effort_and_per_agent_effort_override(tmp_path: Path): with _build_client(tmp_path) as client: - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_app(client)["agents"]} # generate_plan runs on codex and inherits the codex harness effort. assert agents["generate_plan"]["effort"] == "high" assert agents["generate_plan"]["effortSource"] == "harness" # Retune the codex harness effort + override one agent. client.patch("/api/settings/harnesses/codex", json={"effort": "low"}) - client.patch("/api/settings/extensions", json={"agentEfforts": {"generate_plan": "high"}}) - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + client.patch("/api/settings/apps", json={"agentEfforts": {"generate_plan": "high"}}) + agents = {a["name"]: a for a in _ship_app(client)["agents"]} # generate_plan overridden; revise_contract (also codex) inherits "low". assert agents["generate_plan"]["effort"] == "high" assert agents["generate_plan"]["effortSource"] == "agent" @@ -497,27 +495,27 @@ def test_extensions_harness_effort_and_per_agent_effort_override(tmp_path: Path) assert agents["revise_contract"]["effortSource"] == "harness" -def test_extensions_reject_unknown_effort(tmp_path: Path): +def test_apps_reject_unknown_effort(tmp_path: Path): with _build_client(tmp_path) as client: response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"agentEfforts": {"implement": "turbo"}}, ) assert response.status_code == 422 assert "turbo" in response.json()["detail"] -def test_extensions_harness_timeout_and_per_agent_timeout_override(tmp_path: Path): +def test_apps_harness_timeout_and_per_agent_timeout_override(tmp_path: Path): with _build_client(tmp_path) as client: - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_app(client)["agents"]} # implement runs on claude and inherits the claude harness timeout. assert agents["implement"]["timeout"] == 1800 assert agents["implement"]["timeoutSource"] == "harness" # Retune the claude harness timeout + override one agent. client.patch("/api/settings/harnesses/claude", json={"timeout": 1200}) - client.patch("/api/settings/extensions", json={"agentTimeouts": {"implement": 3600}}) - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + client.patch("/api/settings/apps", json={"agentTimeouts": {"implement": 3600}}) + agents = {a["name"]: a for a in _ship_app(client)["agents"]} # implement overridden; review_plan (also claude) inherits 1200. assert agents["implement"]["timeout"] == 3600 assert agents["implement"]["timeoutSource"] == "agent" @@ -525,10 +523,10 @@ def test_extensions_harness_timeout_and_per_agent_timeout_override(tmp_path: Pat assert agents["review_plan"]["timeoutSource"] == "harness" -def test_extensions_reject_non_positive_timeout(tmp_path: Path): +def test_apps_reject_non_positive_timeout(tmp_path: Path): with _build_client(tmp_path) as client: response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"agentTimeouts": {"implement": 0}}, ) assert response.status_code == 422 @@ -537,34 +535,34 @@ def test_extensions_reject_non_positive_timeout(tmp_path: Path): def test_build_review_code_is_a_workflow_setting(tmp_path: Path): """Gating the code reviewer is a build-workflow boolean, not an agent flag.""" with _build_client(tmp_path) as client: - workflow = _ship_extension(client)["workflows"][0] + workflow = _ship_app(client)["workflows"][0] fields = {f["name"]: f for f in workflow["fields"]} assert fields["review_code"]["value"] is True assert fields["review_code"]["overridden"] is False patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {workflow["kind"]: {"review_code": False}}}, ) assert patch.status_code == 200 - fields = {f["name"]: f for f in _ship_extension(client)["workflows"][0]["fields"]} + fields = {f["name"]: f for f in _ship_app(client)["workflows"][0]["fields"]} assert fields["review_code"]["value"] is False assert fields["review_code"]["overridden"] is True -def _extension(client: TestClient, name: str) -> dict: - body = client.get("/api/settings/extensions").json() - return next(m for m in body["extensions"] if m["name"] == name) +def _app(client: TestClient, name: str) -> dict: + body = client.get("/api/settings/apps").json() + return next(m for m in body["apps"] if m["name"] == name) def _refresh_tokens_fields(client: TestClient) -> dict: - workflows = {w["kind"]: w for w in _extension(client, "core")["workflows"]} + workflows = {w["kind"]: w for w in _app(client, "core")["workflows"]} return {f["name"]: f for f in workflows["core.refresh_tokens"]["fields"]} def test_scheduled_workflow_surfaces_schedule_fields(tmp_path: Path): """A workflow's every= surfaces as two ordinary settings fields on the - extension that owns it.""" + app that owns it.""" with _build_client(tmp_path) as client: fields = _refresh_tokens_fields(client) assert fields["schedule"]["value"] == "*/15 * * * *" @@ -583,7 +581,7 @@ def test_schedule_override_persists_and_reconciles(tmp_path: Path, monkeypatch): ) with _build_client(tmp_path) as client: patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={ "workflowSettings": { "core.refresh_tokens": { @@ -605,69 +603,69 @@ def test_schedule_rejects_invalid_cron(tmp_path: Path): # A malformed cron would be silently never-fired by DBOS — reject at the write. with _build_client(tmp_path) as client: patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {"core.refresh_tokens": {"schedule": "not a cron"}}}, ) assert patch.status_code == 422 -def test_extensions_clearing_an_override_reverts_to_the_family_default(tmp_path: Path): +def test_apps_clearing_an_override_reverts_to_the_family_default(tmp_path: Path): with _build_client(tmp_path) as client: client.patch( - "/api/settings/extensions", json={"agentModels": {"generate_plan": "claude-opus-4-7"}} + "/api/settings/apps", json={"agentModels": {"generate_plan": "claude-opus-4-7"}} ) - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + agents = {a["name"]: a for a in _ship_app(client)["agents"]} assert agents["generate_plan"]["model"] == "claude-opus-4-7" assert agents["generate_plan"]["source"] == "agent" # Null clears the override; the agent falls back to its family default. - client.patch("/api/settings/extensions", json={"agentModels": {"generate_plan": None}}) - agents = {a["name"]: a for a in _ship_extension(client)["agents"]} + client.patch("/api/settings/apps", json={"agentModels": {"generate_plan": None}}) + agents = {a["name"]: a for a in _ship_app(client)["agents"]} assert agents["generate_plan"]["model"] == "gpt-5.5" assert agents["generate_plan"]["source"] == "default" -def test_extensions_reject_unknown_agent_model(tmp_path: Path): +def test_apps_reject_unknown_agent_model(tmp_path: Path): with _build_client(tmp_path) as client: # No installed harness owns this namespace, so nothing could run it. response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"agentModels": {"implement": "llama-3-70b"}}, ) assert response.status_code == 422 assert "llama-3-70b" in response.json()["detail"] -def test_extensions_override_workflow_setting_persists(tmp_path: Path): +def test_apps_override_workflow_setting_persists(tmp_path: Path): with _build_client(tmp_path) as client: patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {"ship.build": {"max_implementation_revisions": 8}}}, ) assert patch.status_code == 200 - fields = {f["name"]: f for f in _ship_extension(client)["workflows"][0]["fields"]} + fields = {f["name"]: f for f in _ship_app(client)["workflows"][0]["fields"]} assert fields["max_implementation_revisions"]["value"] == 8 assert fields["max_implementation_revisions"]["overridden"] is True -def test_extensions_plan_gate_override_persists(tmp_path: Path): +def test_apps_plan_gate_override_persists(tmp_path: Path): with _build_client(tmp_path) as client: patch = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {"ship.build": {"plan_gate": "machine_then_human"}}}, ) assert patch.status_code == 200 - fields = {f["name"]: f for f in _ship_extension(client)["workflows"][0]["fields"]} + fields = {f["name"]: f for f in _ship_app(client)["workflows"][0]["fields"]} assert fields["plan_gate"]["value"] == "machine_then_human" assert fields["plan_gate"]["overridden"] is True -def test_extensions_reject_removed_auto_dispatch_setting(tmp_path: Path): +def test_apps_reject_removed_auto_dispatch_setting(tmp_path: Path): with _build_client(tmp_path) as client: response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {"ship.build": {"auto_dispatch_on_plan_approval": True}}}, ) @@ -676,10 +674,10 @@ def test_extensions_reject_removed_auto_dispatch_setting(tmp_path: Path): assert detail == "Unknown ship.build setting 'auto_dispatch_on_plan_approval'" -def test_extensions_reject_out_of_range_workflow_setting(tmp_path: Path): +def test_apps_reject_out_of_range_workflow_setting(tmp_path: Path): with _build_client(tmp_path) as client: response = client.patch( - "/api/settings/extensions", + "/api/settings/apps", json={"workflowSettings": {"ship.build": {"max_implementation_revisions": 99}}}, ) assert response.status_code == 422 diff --git a/backend/tests/test_api_usage.py b/backend/tests/test_api_usage.py index 0097282f..3c2cf087 100644 --- a/backend/tests/test_api_usage.py +++ b/backend/tests/test_api_usage.py @@ -17,13 +17,13 @@ @pytest.fixture -def extension_settings(tmp_path: Path) -> Settings: +def app_settings(tmp_path: Path) -> Settings: return make_settings(tmp_path) @pytest.fixture -def client(extension_settings: Settings): - with TestClient(configure_app_for_test(settings=extension_settings)) as c: +def client(app_settings: Settings): + with TestClient(configure_app_for_test(settings=app_settings)) as c: yield c @@ -81,7 +81,7 @@ def test_get_usage_empty_returns_available_false(client) -> None: assert all(entry["available"] is False for entry in body["harnesses"]) -def test_get_usage_serializes_latest_per_harness(client, extension_settings) -> None: +def test_get_usage_serializes_latest_per_harness(client, app_settings) -> None: # Plant a snapshot for claude only — codex should still report # ``available=false`` rather than missing-key/404. _seed( @@ -120,7 +120,7 @@ def test_get_usage_serializes_latest_per_harness(client, extension_settings) -> assert _harness(body, "codex")["available"] is False -def test_get_usage_flags_stale_after_24h(client, extension_settings) -> None: +def test_get_usage_flags_stale_after_24h(client, app_settings) -> None: _seed( [ UsageScrape( @@ -136,7 +136,7 @@ def test_get_usage_flags_stale_after_24h(client, extension_settings) -> None: assert _harness(body, "claude")["stale"] is True -def test_get_usage_exposes_unlimited_flag(client, extension_settings) -> None: +def test_get_usage_exposes_unlimited_flag(client, app_settings) -> None: # Codex business plan: scraper synthesizes permanently-full buckets # and marks the row unmetered so the UI can render "unmetered" # instead of a quota bar that never moves. @@ -159,7 +159,7 @@ def test_get_usage_exposes_unlimited_flag(client, extension_settings) -> None: assert _harness(body, "claude")["unlimited"] is False -def test_usage_history_serializes_series_oldest_first(client, extension_settings) -> None: +def test_usage_history_serializes_series_oldest_first(client, app_settings) -> None: now = datetime.now(UTC) snaps = [ UsageScrape( @@ -214,7 +214,7 @@ def test_usage_history_serializes_series_oldest_first(client, extension_settings def test_usage_today_aggregates_spend_and_tokens_by_provider( - client, extension_settings, druks_db + client, app_settings, druks_db ) -> None: codex_run = _seed_agent_call(druks_db, model="gpt-5.5") codex_run.account_id = _account_id() diff --git a/backend/tests/test_extension_doctor_checks.py b/backend/tests/test_app_doctor_checks.py similarity index 65% rename from backend/tests/test_extension_doctor_checks.py rename to backend/tests/test_app_doctor_checks.py index a3a5fbb9..e70d9121 100644 --- a/backend/tests/test_extension_doctor_checks.py +++ b/backend/tests/test_app_doctor_checks.py @@ -2,15 +2,15 @@ import pytest from druks import doctor +from druks.apps import App, AppSettings from druks.database import db_session -from druks.extensions import Extension, ExtensionSettings from druks.testing import make_settings from druks.user_settings.models import SettingsOverride -# field_notes is the out-of-tree proof extension (``backend/tests/druks-field_notes``). +# field_notes is the out-of-tree proof app (``backend/tests/druks-field_notes``). # It declares settings coherence and one check on its class. These tests drive both -# through the platform's own doctor without doctor importing the extension's private -# modules, and prove that a broken extension check can't hide a core one. +# through the platform's own doctor without doctor importing the app's private +# modules, and prove that a broken app check can't hide a core one. @pytest.fixture @@ -24,40 +24,38 @@ def _named(results: list[doctor.CheckResult], name: str) -> doctor.CheckResult: return next(result for result in results if result.name == name) -def test_passing_extension_check_reports_under_the_extension( +def test_passing_app_check_reports_under_the_app( installed, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A satisfied precondition passes and its result is namespaced under the extension + """A satisfied precondition passes and its result is namespaced under the app name — the API-key check with the credential set.""" monkeypatch.setenv("FIELD_NOTES_API_KEY", "sk-test") settings = make_settings(tmp_path) - result = _named(doctor.check_extensions(settings), "field_notes:summary_api_key") + result = _named(doctor.check_apps(settings), "field_notes:summary_api_key") assert result.ok assert result.detail == "set" -def test_failing_extension_check_reports_under_the_extension(installed, tmp_path: Path) -> None: - """The extension's API-key check fails when the credential is unset, reported - under the extension name so the operator knows which extension is broken.""" +def test_failing_app_check_reports_under_the_app(installed, tmp_path: Path) -> None: + """The app's API-key check fails when the credential is unset, reported + under the app name so the operator knows which app is broken.""" settings = make_settings(tmp_path) - result = _named(doctor.check_extensions(settings), "field_notes:summary_api_key") + result = _named(doctor.check_apps(settings), "field_notes:summary_api_key") assert not result.ok assert "FIELD_NOTES_API_KEY" in result.detail -def test_unreachable_settings_database_is_an_extension_check_failure( - installed, tmp_path: Path -) -> None: +def test_unreachable_settings_database_is_an_app_check_failure(installed, tmp_path: Path) -> None: settings = make_settings( tmp_path, database_url="postgresql+psycopg://druks:druks@127.0.0.1:1/druks", ) - results = doctor.check_extensions(settings) + results = doctor.check_apps(settings) result = _named(results, "ship:settings") assert not result.ok @@ -71,7 +69,7 @@ def test_selected_unconnected_tracker_pends_through_ships_own_check( monkeypatch.setattr(doctor, "create_engine_from_url", lambda _: druks_db.get_bind()) try: - result = _named(doctor.check_extensions(make_settings(tmp_path)), "ship:tracker") + result = _named(doctor.check_apps(make_settings(tmp_path)), "ship:tracker") finally: db_session.registry.set(druks_db) @@ -86,11 +84,11 @@ def test_half_configured_review_identity_fails_through_review_settings( # Review identity health is Review's own: the incoherent pair fails under # ``review:settings`` while the set/unset check stays healthy — no core # doctor check hardcodes review knowledge, and no GitHub call is made. - SettingsOverride.set_extension_setting("review", "app_id", "42", is_secret=True) + SettingsOverride.set_app_setting("review", "app_id", "42", is_secret=True) monkeypatch.setattr(doctor, "create_engine_from_url", lambda _: druks_db.get_bind()) try: - results = doctor.check_extensions(make_settings(tmp_path)) + results = doctor.check_apps(make_settings(tmp_path)) finally: db_session.registry.set(druks_db) @@ -105,13 +103,13 @@ def test_half_configured_review_identity_fails_through_review_settings( def test_coherent_stored_settings_pass( installed, tmp_path: Path, druks_db, monkeypatch: pytest.MonkeyPatch ) -> None: - SettingsOverride.set_extension_setting( + SettingsOverride.set_app_setting( "ship", "linear_trigger_status", "Agent Queue", is_secret=False ) monkeypatch.setattr(doctor, "create_engine_from_url", lambda _: druks_db.get_bind()) try: - result = _named(doctor.check_extensions(make_settings(tmp_path)), "ship:settings") + result = _named(doctor.check_apps(make_settings(tmp_path)), "ship:settings") finally: db_session.registry.set(druks_db) @@ -119,54 +117,54 @@ def test_coherent_stored_settings_pass( assert result.detail == "coherent" -def test_extension_without_settings_has_no_settings_row( +def test_app_without_settings_has_no_settings_row( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - class Plain(Extension): + class Plain(App): name = "plain_without_settings" - monkeypatch.setattr(doctor, "iter_extensions", lambda: iter([Plain])) + monkeypatch.setattr(doctor, "iter_apps", lambda: iter([Plain])) - assert doctor.check_extensions(make_settings(tmp_path)) == [] + assert doctor.check_apps(make_settings(tmp_path)) == [] def test_raising_settings_clean_is_contained( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - class Broken(Extension): + class Broken(App): name = "broken_settings" - class Settings(ExtensionSettings): + class Settings(AppSettings): def clean(self) -> dict[str, str]: raise RuntimeError("coherence crashed") - monkeypatch.setattr(doctor, "iter_extensions", lambda: iter([Broken])) + monkeypatch.setattr(doctor, "iter_apps", lambda: iter([Broken])) - result = _named(doctor.check_extensions(make_settings(tmp_path)), "broken_settings:settings") + result = _named(doctor.check_apps(make_settings(tmp_path)), "broken_settings:settings") assert not result.ok assert "coherence crashed" in result.detail -def test_extension_checks_are_wired_into_the_check_battery(installed, tmp_path: Path) -> None: - """``run_checks`` runs the extension checks: ``check_extensions`` is one of the +def test_app_checks_are_wired_into_the_check_battery(installed, tmp_path: Path) -> None: + """``run_checks`` runs the app checks: ``check_apps`` is one of the battery's entries and, like ``check_harness_credentials``, fans its several - results into the run — so the extension's checks reach the report beside core's.""" + results into the run — so the app's checks reach the report beside core's.""" settings = make_settings(tmp_path) - assert doctor.check_extensions in doctor.CHECKS + assert doctor.check_apps in doctor.CHECKS - extension_results = doctor.check_extensions(settings) - assert isinstance(extension_results, list) - assert "field_notes:summary_api_key" in {result.name for result in extension_results} + app_results = doctor.check_apps(settings) + assert isinstance(app_results, list) + assert "field_notes:summary_api_key" in {result.name for result in app_results} -def test_raising_extension_check_is_isolated_and_does_not_stop_siblings( +def test_raising_app_check_is_isolated_and_does_not_stop_siblings( installed, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A check that raises becomes one failing result tagged with the extension name, - and the extension's other checks still run.""" - from druks_field_notes import extension as field_notes + """A check that raises becomes one failing result tagged with the app name, + and the app's other checks still run.""" + from druks_field_notes import app as field_notes def boom() -> doctor.CheckResult: raise RuntimeError("provider unreachable") @@ -177,7 +175,7 @@ def healthy() -> doctor.CheckResult: monkeypatch.setattr(field_notes.FieldNotes, "checks", [boom, healthy]) settings = make_settings(tmp_path) - results = doctor.check_extensions(settings) + results = doctor.check_apps(settings) raised = _named(results, "field_notes:boom") assert not raised.ok @@ -186,14 +184,14 @@ def healthy() -> doctor.CheckResult: assert _named(results, "field_notes:healthy").ok -def test_broken_extension_check_does_not_hide_core_failures( +def test_broken_app_check_does_not_hide_core_failures( installed, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The key robustness contract: a raising extension check is contained inside - ``check_extensions``, and a failing core check still reports its failure. Both + """The key robustness contract: a raising app check is contained inside + ``check_apps``, and a failing core check still reports its failure. Both are independent entries in ``CHECKS``, so ``run_checks`` runs them side by side — - a broken extension can't abort or hide the core checks.""" - from druks_field_notes import extension as field_notes + a broken app can't abort or hide the core checks.""" + from druks_field_notes import app as field_notes def boom() -> doctor.CheckResult: raise RuntimeError("kaboom") @@ -203,13 +201,13 @@ def boom() -> doctor.CheckResult: settings = make_settings(tmp_path, redis_url="redis://127.0.0.1:1/0") # Both are entries in the battery, so run_checks runs them independently. - assert doctor.check_extensions in doctor.CHECKS + assert doctor.check_apps in doctor.CHECKS assert doctor.check_redis in doctor.CHECKS - # The extension's raising check is contained as a failure under its own name… - extension_result = _named(doctor.check_extensions(settings), "field_notes:boom") - assert not extension_result.ok - assert "kaboom" in extension_result.detail + # The app's raising check is contained as a failure under its own name… + app_result = _named(doctor.check_apps(settings), "field_notes:boom") + assert not app_result.ok + assert "kaboom" in app_result.detail # …and the core check, a separate battery entry, still runs and still fails. redis_result = doctor.check_redis(settings) @@ -217,12 +215,12 @@ def boom() -> doctor.CheckResult: assert "127.0.0.1:1" in redis_result.detail -def test_default_extension_contributes_no_checks(tmp_path: Path) -> None: - """An extension that doesn't declare ``checks`` adds nothing because the base +def test_default_app_contributes_no_checks(tmp_path: Path) -> None: + """An app that doesn't declare ``checks`` adds nothing because the base attribute is an empty list.""" - from druks.extensions import Extension + from druks.apps import App - class Plain(Extension): + class Plain(App): name = "plain_probe" assert Plain.checks == [] @@ -234,7 +232,7 @@ def test_malformed_check_return_is_contained( """A check that returns something other than a ``CheckResult`` — a missing ``return`` yields ``None`` — becomes a failing result under its name rather than crashing the run with ``AttributeError`` and hiding later checks.""" - from druks_field_notes import extension as field_notes + from druks_field_notes import app as field_notes def check_forgot_return() -> doctor.CheckResult: return None # type: ignore[return-value] # the bug under test: no real return @@ -246,7 +244,7 @@ def healthy() -> doctor.CheckResult: monkeypatch.setattr(field_notes.FieldNotes, "checks", [check_forgot_return, healthy]) settings = make_settings(tmp_path) - results = doctor.check_extensions(settings) + results = doctor.check_apps(settings) by_name = {result.name: result for result in results} # The malformed return is contained as a failure under its own name… diff --git a/backend/tests/test_extension_appless_load.py b/backend/tests/test_app_headless_load.py similarity index 55% rename from backend/tests/test_extension_appless_load.py rename to backend/tests/test_app_headless_load.py index b9799a89..dc575cbf 100644 --- a/backend/tests/test_extension_appless_load.py +++ b/backend/tests/test_app_headless_load.py @@ -4,34 +4,34 @@ from pathlib import Path import pytest -from druks.extensions import loader -from druks.extensions.exceptions import ( - ExtensionImportError, - ExtensionLoadError, - ExtensionNotFound, - ExtensionSubjectContractError, - MalformedExtension, +from druks.apps import loader +from druks.apps.exceptions import ( + AppImportError, + AppLoadError, + AppNotFound, + AppSubjectContractError, + MalformedApp, ) -from druks.extensions.loader import load_extension +from druks.apps.loader import load_app -# An out-of-tree extension package, written to disk and put on sys.path so a real +# An out-of-tree app package, written to disk and put on sys.path so a real # importlib.metadata.EntryPoint resolves it — the same machinery an editable # ``pip install -e`` would wire, without mutating the shared environment's # installed dist metadata. The whole point is a package that lives outside the -# druks tree, loaded app-lessly. Built once per module so its ``Base`` model is +# druks tree, loaded headlessly. Built once per module so its ``Base`` model is # declared exactly once (re-declaring a mapped class into the shared metadata # collides); the per-test loads are idempotent re-imports. _PACKAGE = "druks_probe" _FILES = { - "extension.py": """ - from druks.extensions import Extension, ExtensionSettings + "app.py": """ + from druks.apps import App, AppSettings from pydantic import Field - class Probe(Extension): + class Probe(App): name = "probe" - class Settings(ExtensionSettings): + class Settings(AppSettings): budget: int = Field(default=3, ge=1) """, "models.py": """ @@ -95,11 +95,11 @@ class Settings(BaseModel): # A package whose declared ``StoredSubject`` omits ``list_summaries()``. Its own # table keeps it off the conforming probe's shared metadata. _BROKEN_STORED_FILES = { - "extension.py": """ - from druks.extensions import Extension + "app.py": """ + from druks.apps import App - class BrokenStored(Extension): + class BrokenStored(App): name = "brokenstored" """, "models.py": """ @@ -134,17 +134,17 @@ def _write_package(root: Path, package: str, files: dict[str, str]) -> None: def _entry(package: str) -> EntryPoint: - return EntryPoint(name="probe", value=f"{package}.extension:Probe", group="druks.extensions") + return EntryPoint(name="probe", value=f"{package}.app:Probe", group="druks.apps") @pytest.fixture(scope="module") -def external_extension(tmp_path_factory): - """Build the probe package, expose it as the sole installed ``druks.extensions`` +def external_app(tmp_path_factory): + """Build the probe package, expose it as the sole installed ``druks.apps`` entry point, and restore every global its load mutates (registries, table metadata, signal receivers) so the suite stays clean.""" from blinker import signal - from druks.extensions import loader as extensions_loader - from druks.extensions.registry import agents, services, webhooks, workflows + from druks.apps import loader as apps_loader + from druks.apps.registry import agents, services, webhooks, workflows from druks.models import Base root = tmp_path_factory.mktemp("external") @@ -155,7 +155,7 @@ def external_extension(tmp_path_factory): registries = { registry: dict(registry._items) for registry in (agents, services, webhooks, workflows) } - packages = dict(extensions_loader._workflow_packages) + packages = dict(apps_loader._workflow_packages) finished = signal("workflow.finished") receivers = dict(finished.receivers) try: @@ -166,207 +166,199 @@ def external_extension(tmp_path_factory): Base.metadata.remove(Base.metadata.tables[name]) for registry, snapshot in registries.items(): registry._items = snapshot - extensions_loader._workflow_packages.clear() - extensions_loader._workflow_packages.update(packages) + apps_loader._workflow_packages.clear() + apps_loader._workflow_packages.update(packages) finished.receivers = receivers for name in [m for m in sys.modules if m == _PACKAGE or m.startswith(f"{_PACKAGE}.")]: del sys.modules[name] @pytest.fixture -def installed(external_extension, monkeypatch): +def installed(external_app, monkeypatch): """The probe entry point as the only one the loader sees.""" - monkeypatch.setattr(loader, "entry_points", lambda *, group: [external_extension]) - return external_extension + monkeypatch.setattr(loader, "entry_points", lambda *, group: [external_app]) + return external_app -def test_loads_an_external_extension_without_the_web_app(installed): - """An out-of-tree, entry-point-declared extension loads with no FastAPI app.""" - extension = load_extension("probe") +def test_loads_an_external_app_without_the_web_app(installed): + """An out-of-tree, entry-point-declared app loads with no FastAPI app.""" + app = load_app("probe") - assert extension.name == "probe" - assert extension.package == _PACKAGE + assert app.name == "probe" + assert app.package == _PACKAGE -def test_load_registers_the_extensions_tables(installed): - """Loading imports the extension's models, registering its prefixed tables.""" +def test_load_registers_the_apps_tables(installed): + """Loading imports the app's models, registering its prefixed tables.""" from druks.models import Base - load_extension("probe") + load_app("probe") assert "probe_items" in Base.metadata.tables -def test_surfaces_are_enumerable_from_the_loaded_extension(installed): +def test_surfaces_are_enumerable_from_the_loaded_app(installed): """Workflows, routes, subscribers, settings, and migrations all read off the - loaded extension without booting the platform.""" - extension = load_extension("probe") + loaded app without booting the platform.""" + app = load_app("probe") - assert [workflow.__name__ for workflow in extension.workflows()] == ["Inspect"] + assert [workflow.__name__ for workflow in app.workflows()] == ["Inspect"] - router_prefixes = {router.prefix for router in extension.routers()} - assert "/widgets" in router_prefixes # the extension's own router + router_prefixes = {router.prefix for router in app.routers()} + assert "/widgets" in router_prefixes # the app's own router assert "/transcripts/{call_id}" in router_prefixes # the free read-side # Its one workflow declares ProbeItem, so the subject read-side mounts too. assert "/probe_item" in router_prefixes - capability_modules = {module.__name__ for module in extension.capability_modules()} + capability_modules = {module.__name__ for module in app.capability_modules()} assert f"{_PACKAGE}.subscribers" in capability_modules - settings_model = extension.settings_model + settings_model = app.settings_model assert settings_model assert list(settings_model.model_fields) == ["budget"] - package_dir = extension.package_dir() + package_dir = app.package_dir() assert package_dir - assert extension.migrations_dir() == package_dir / "migrations" + assert app.migrations_dir() == package_dir / "migrations" -def test_load_registers_the_extensions_services(installed): +def test_load_registers_the_apps_services(installed): """The ``services`` module is a discovered role: its declarations register on load.""" - from druks.extensions.registry import services + from druks.apps.registry import services - load_extension("probe") + load_app("probe") assert services.get("probemail").title == "Probemail" -def test_missing_package_raises_extension_not_found(installed): - """A name no installed package declares fails as ExtensionNotFound.""" - with pytest.raises(ExtensionNotFound, match="no installed extension named 'ghost'"): - load_extension("ghost") +def test_missing_package_raises_app_not_found(installed): + """A name no installed package declares fails as AppNotFound.""" + with pytest.raises(AppNotFound, match="no installed app named 'ghost'"): + load_app("ghost") def test_named_failures_share_one_load_error_base(installed): - """Every load failure is catchable as ExtensionLoadError — one except for callers.""" - with pytest.raises(ExtensionLoadError): - load_extension("ghost") + """Every load failure is catchable as AppLoadError — one except for callers.""" + with pytest.raises(AppLoadError): + load_app("ghost") -def test_malformed_entry_point_raises_malformed_extension(monkeypatch): - """An entry point resolving to a non-Extension fails as MalformedExtension.""" - entry = EntryPoint(name="bad", value="builtins:object", group="druks.extensions") +def test_malformed_entry_point_raises_malformed_app(monkeypatch): + """An entry point resolving to a non-App fails as MalformedApp.""" + entry = EntryPoint(name="bad", value="builtins:object", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry]) - with pytest.raises(MalformedExtension, match="is not an Extension"): - load_extension("bad") + with pytest.raises(MalformedApp, match="is not an App"): + load_app("bad") -def test_duplicate_entry_points_raise_malformed_extension(monkeypatch): +def test_duplicate_entry_points_raise_malformed_app(monkeypatch): """Two installed packages claiming one name is a broken install — the loader fails loudly rather than silently loading an arbitrary one.""" duplicates = [ - EntryPoint(name="probe", value="one.extension:Probe", group="druks.extensions"), - EntryPoint(name="probe", value="two.extension:Probe", group="druks.extensions"), + EntryPoint(name="probe", value="one.app:Probe", group="druks.apps"), + EntryPoint(name="probe", value="two.app:Probe", group="druks.apps"), ] monkeypatch.setattr(loader, "entry_points", lambda *, group: duplicates) - with pytest.raises(MalformedExtension, match="declared by 2 installed packages"): - load_extension("probe") + with pytest.raises(MalformedApp, match="declared by 2 installed packages"): + load_app("probe") -def test_load_does_not_import_sibling_extensions(installed, monkeypatch): - """Loading one extension imports only its own package — a single-extension load +def test_load_does_not_import_sibling_apps(installed, monkeypatch): + """Loading one app imports only its own package — a single-app load must not pull sibling entry modules and pollute the global registries.""" - sibling = EntryPoint( - name="other", value="druks_never_imported.extension:Other", group="druks.extensions" - ) + sibling = EntryPoint(name="other", value="druks_never_imported.app:Other", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [installed, sibling]) - load_extension("probe") + load_app("probe") assert "druks_never_imported" not in sys.modules - assert "druks_never_imported.extension" not in sys.modules + assert "druks_never_imported.app" not in sys.modules -def test_unresolvable_entry_point_target_raises_malformed_extension(monkeypatch): - """An entry point whose target attribute doesn't exist fails as MalformedExtension, +def test_unresolvable_entry_point_target_raises_malformed_app(monkeypatch): + """An entry point whose target attribute doesn't exist fails as MalformedApp, not as a raw AttributeError leaking from importlib.""" - entry = EntryPoint( - name="bad", value="druks.extensions.base:NoSuchClass", group="druks.extensions" - ) + entry = EntryPoint(name="bad", value="druks.apps.base:NoSuchClass", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry]) - with pytest.raises(MalformedExtension, match="doesn't define"): - load_extension("bad") + with pytest.raises(MalformedApp, match="doesn't define"): + load_app("bad") -def test_missing_entry_module_raises_malformed_extension(monkeypatch): +def test_missing_entry_module_raises_malformed_app(monkeypatch): """An entry point pointing at a module that isn't installed is a packaging - mistake — MalformedExtension, not ExtensionImportError.""" - entry = EntryPoint(name="bad", value="not_installed_pkg.extension:X", group="druks.extensions") + mistake — MalformedApp, not AppImportError.""" + entry = EntryPoint(name="bad", value="not_installed_pkg.app:X", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry]) - with pytest.raises(MalformedExtension, match="isn't installed"): - load_extension("bad") + with pytest.raises(MalformedApp, match="isn't installed"): + load_app("bad") -def test_entry_point_key_mismatch_raises_malformed_extension(installed, monkeypatch): +def test_entry_point_key_mismatch_raises_malformed_app(installed, monkeypatch): """An entry-point key that doesn't equal the class's ``name`` — the key scopes the namespaces, so a mismatch is malformed.""" - aliased = EntryPoint( - name="not_probe", value=f"{_PACKAGE}.extension:Probe", group="druks.extensions" - ) + aliased = EntryPoint(name="not_probe", value=f"{_PACKAGE}.app:Probe", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [aliased]) - with pytest.raises(MalformedExtension, match="must match Extension.name"): - load_extension("not_probe") + with pytest.raises(MalformedApp, match="must match App.name"): + load_app("not_probe") def test_boot_rejects_an_entry_point_key_mismatch(installed, monkeypatch): """Full boot applies the same key/name validation as the single load — an - aliased entry the app-less path rejects must not slip through iter_extensions().""" - aliased = EntryPoint( - name="not_probe", value=f"{_PACKAGE}.extension:Probe", group="druks.extensions" - ) + aliased entry the headless path rejects must not slip through iter_apps().""" + aliased = EntryPoint(name="not_probe", value=f"{_PACKAGE}.app:Probe", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [aliased]) - with pytest.raises(MalformedExtension, match="must match Extension.name"): - loader.iter_extensions() + with pytest.raises(MalformedApp, match="must match App.name"): + loader.iter_apps() -def test_import_error_in_entry_module_raises_extension_import_error(tmp_path, monkeypatch): - """The extension's entry module raising on import (e.g. a missing dependency it - imports) surfaces as ExtensionImportError — the extension's code failed, distinct +def test_import_error_in_entry_module_raises_app_import_error(tmp_path, monkeypatch): + """The app's entry module raising on import (e.g. a missing dependency it + imports) surfaces as AppImportError — the app's code failed, distinct from a packaging target mistake.""" package = "druks_import_boom" directory = tmp_path / package (directory / "migrations" / "versions").mkdir(parents=True) (directory / "__init__.py").write_text("") - (directory / "extension.py").write_text( + (directory / "app.py").write_text( "import totally_absent_dependency # noqa: F401\n" - "from druks.extensions import Extension\n\n\n" - "class Boom(Extension):\n name = 'boom'\n" + "from druks.apps import App\n\n\n" + "class Boom(App):\n name = 'boom'\n" ) monkeypatch.syspath_prepend(str(tmp_path)) - entry = EntryPoint(name="boom", value=f"{package}.extension:Boom", group="druks.extensions") + entry = EntryPoint(name="boom", value=f"{package}.app:Boom", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry]) - with pytest.raises(ExtensionImportError, match="entry module") as caught: - load_extension("boom") + with pytest.raises(AppImportError, match="entry module") as caught: + load_app("boom") assert isinstance(caught.value.__cause__, ModuleNotFoundError) -def test_import_error_in_models_raises_extension_import_error(tmp_path, monkeypatch): - """A well-declared extension whose models module raises on import surfaces as - ExtensionImportError, carrying the original exception as its cause.""" +def test_import_error_in_models_raises_app_import_error(tmp_path, monkeypatch): + """A well-declared app whose models module raises on import surfaces as + AppImportError, carrying the original exception as its cause.""" package = "druks_broken_probe" files = {**_FILES, "models.py": "raise RuntimeError('boom on import')\n"} _write_package(tmp_path, package, files) monkeypatch.syspath_prepend(str(tmp_path)) monkeypatch.setattr(loader, "entry_points", lambda *, group: [_entry(package)]) - with pytest.raises(ExtensionImportError, match="failed to import") as caught: - load_extension("probe") + with pytest.raises(AppImportError, match="failed to import") as caught: + load_app("probe") assert isinstance(caught.value.__cause__, RuntimeError) @pytest.fixture -def broken_stored_extension(tmp_path_factory, monkeypatch): +def broken_stored_app(tmp_path_factory, monkeypatch): """The broken package as the only installed entry point; restores the globals its load mutates.""" - from druks.extensions import loader as extensions_loader - from druks.extensions.registry import agents, services, webhooks, workflows + from druks.apps import loader as apps_loader + from druks.apps.registry import agents, services, webhooks, workflows from druks.models import Base package = "druks_broken_stored" @@ -378,10 +370,8 @@ def broken_stored_extension(tmp_path_factory, monkeypatch): registries = { registry: dict(registry._items) for registry in (agents, services, webhooks, workflows) } - packages = dict(extensions_loader._workflow_packages) - entry = EntryPoint( - name="brokenstored", value=f"{package}.extension:BrokenStored", group="druks.extensions" - ) + packages = dict(apps_loader._workflow_packages) + entry = EntryPoint(name="brokenstored", value=f"{package}.app:BrokenStored", group="druks.apps") monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry]) try: yield @@ -391,19 +381,19 @@ def broken_stored_extension(tmp_path_factory, monkeypatch): Base.metadata.remove(Base.metadata.tables[name]) for registry, snapshot in registries.items(): registry._items = snapshot - extensions_loader._workflow_packages.clear() - extensions_loader._workflow_packages.update(packages) + apps_loader._workflow_packages.clear() + apps_loader._workflow_packages.update(packages) for name in [m for m in sys.modules if m == package or m.startswith(f"{package}.")]: del sys.modules[name] -def test_appless_load_rejects_a_stored_subject_missing_list_summaries(broken_stored_extension): +def test_headless_load_rejects_a_stored_subject_missing_list_summaries(broken_stored_app): """A row-backed subject is gated the same as ``Subject`` — typed, not an import error.""" - with pytest.raises(ExtensionSubjectContractError) as caught: - load_extension("brokenstored") + with pytest.raises(AppSubjectContractError) as caught: + load_app("brokenstored") message = str(caught.value) - assert "brokenstored" in message # the extension name + assert "brokenstored" in message # the app name assert "Ledger" in message # the subject class assert "list_summaries()" in message # the missing method assert "Implement list_summaries()" in message # the implementation direction - assert isinstance(caught.value, ExtensionLoadError) + assert isinstance(caught.value, AppLoadError) diff --git a/backend/tests/test_extension_loader.py b/backend/tests/test_app_loader.py similarity index 52% rename from backend/tests/test_extension_loader.py rename to backend/tests/test_app_loader.py index 9a0eed7f..243fb4b5 100644 --- a/backend/tests/test_extension_loader.py +++ b/backend/tests/test_app_loader.py @@ -3,23 +3,23 @@ _PLATFORM_ROOT = Path(__file__).resolve().parent.parent / "druks" -def test_import_extension_models_registers_ship_via_generic_discovery(): +def test_import_app_models_registers_ship_via_generic_discovery(): # Ship's tables are unprefixed (they live in core's schema), so it flows through the - # same iter_extensions() path as any extension — exempt via prefix_tables=False, not a + # same iter_apps() path as any app — exempt via prefix_tables=False, not a # hardcoded platform import. - from druks.extensions.loader import get_extension, import_extension_models + from druks.apps.loader import get_app, import_app_models from druks.models import Base - assert get_extension("ship").prefix_tables is False - import_extension_models() # idempotent; raises if the unprefixed tables aren't exempt + assert get_app("ship").prefix_tables is False + import_app_models() # idempotent; raises if the unprefixed tables aren't exempt assert {"projects", "work_items", "project_repos"} <= set(Base.metadata.tables) -def test_platform_does_not_import_an_extension_package(): +def test_platform_does_not_import_an_app_package(): # Regression guard for the inverted dependency: the loader and the db bootstrap must - # not name an extension — extensions register through discovery, so removing or + # not name an app — apps register through discovery, so removing or # unbundling one can't break init_db. - for module in ("extensions/loader.py", "database.py"): + for module in ("apps/loader.py", "database.py"): source = (_PLATFORM_ROOT / module).read_text() assert "import druks.contrib" not in source, f"{module} imports the contrib namespace" - assert "import druks.usage" not in source, f"{module} imports the usage extension" + assert "import druks.usage" not in source, f"{module} imports the usage app" diff --git a/backend/tests/test_extension_migrations.py b/backend/tests/test_app_migrations.py similarity index 83% rename from backend/tests/test_extension_migrations.py rename to backend/tests/test_app_migrations.py index d9f77ce5..3301c59b 100644 --- a/backend/tests/test_extension_migrations.py +++ b/backend/tests/test_app_migrations.py @@ -6,9 +6,9 @@ from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine # The real platform ``alembic.ini`` — its script_location is the one shared env.py -# that serves every extension. These tests run a synthetic extension's revisions through it +# that serves every app. These tests run a synthetic app's revisions through it # from an external version_locations, proving the target shape: shared env, the -# extension's own version_locations and version_table, isolated from core's history. +# app's own version_locations and version_table, isolated from core's history. _ALEMBIC_INI = Path(__file__).resolve().parent.parent / "alembic.ini" @@ -50,17 +50,17 @@ def _drop(conn) -> None: conn.exec_driver_sql("DROP TABLE IF EXISTS ext_probe, alembic_version_ext, alembic_version") -def test_extension_upgrade_runs_through_platform_env_with_its_own_version_table(tmp_path): - """An extension's revisions, run through the shared platform env from an external - ``version_locations``, track their head in the extension's own - ``alembic_version_`` — so a foreign head in core's default table doesn't +def test_app_upgrade_runs_through_platform_env_with_its_own_version_table(tmp_path): + """An app's revisions, run through the shared platform env from an external + ``version_locations``, track their head in the app's own + ``alembic_version_`` — so a foreign head in core's default table doesn't derail them, and core's own scripts (the env's default ``versions/``) don't leak in.""" versions = _versions_dir(tmp_path) engine = create_engine(TEST_DATABASE_URL, isolation_level="AUTOCOMMIT") with engine.connect() as conn: _drop(conn) - # Core already at head: the shared default table holds a revision the extension's + # Core already at head: the shared default table holds a revision the app's # own history has never heard of. conn.exec_driver_sql("CREATE TABLE alembic_version (version_num varchar(32) NOT NULL)") conn.exec_driver_sql("INSERT INTO alembic_version VALUES ('b7e4f0a1c2d3')") @@ -73,7 +73,7 @@ def test_extension_upgrade_runs_through_platform_env_with_its_own_version_table( == "ext0001" ) # Core's default table is untouched — version_locations replaced the - # default, so core's own scripts never ran in the extension's pass. + # default, so core's own scripts never ran in the app's pass. assert ( conn.exec_driver_sql("SELECT version_num FROM alembic_version").scalar() == "b7e4f0a1c2d3" @@ -84,16 +84,16 @@ def test_extension_upgrade_runs_through_platform_env_with_its_own_version_table( engine.dispose() -def test_extension_autogenerate_scopes_to_the_extension_metadata(tmp_path): +def test_app_autogenerate_scopes_to_the_app_metadata(tmp_path): """``revision --autogenerate`` through the platform env diffs only the scoped - metadata against the live DB and writes the revision into the extension's own + metadata against the live DB and writes the revision into the app's own ``versions/`` — reflected tables it doesn't own are left alone.""" versions = _versions_dir(tmp_path) engine = create_engine(TEST_DATABASE_URL, isolation_level="AUTOCOMMIT") with engine.connect() as conn: _drop(conn) try: - # Baseline applied: the DB has ext_probe(id) at the extension's head. + # Baseline applied: the DB has ext_probe(id) at the app's head. command.upgrade(_config(versions, version_table="alembic_version_ext"), "head") scoped = MetaData() diff --git a/backend/tests/test_extension_roster.py b/backend/tests/test_app_roster.py similarity index 87% rename from backend/tests/test_extension_roster.py rename to backend/tests/test_app_roster.py index f77c1685..0e09ab70 100644 --- a/backend/tests/test_extension_roster.py +++ b/backend/tests/test_app_roster.py @@ -4,9 +4,9 @@ from fastapi.testclient import TestClient -def test_roster_lists_installed_extensions_with_subject_types(tmp_path: Path): +def test_roster_lists_installed_apps_with_subject_types(tmp_path: Path): with TestClient(configure_app_for_test(settings=make_settings(tmp_path))) as client: - roster = {entry["name"]: entry for entry in client.get("/api/extensions").json()} + roster = {entry["name"]: entry for entry in client.get("/api/apps").json()} ship = roster["ship"] assert ship["builtin"] is False diff --git a/backend/tests/test_extensions.py b/backend/tests/test_apps.py similarity index 64% rename from backend/tests/test_extensions.py rename to backend/tests/test_apps.py index 8e0875c6..19977f16 100644 --- a/backend/tests/test_extensions.py +++ b/backend/tests/test_apps.py @@ -1,17 +1,17 @@ from types import ModuleType, SimpleNamespace import pytest -from druks.extensions import Extension, loader -from druks.extensions.exceptions import ExtensionSubjectContractError -from druks.extensions.loader import iter_extensions, load +from druks.apps import App, loader +from druks.apps.exceptions import AppSubjectContractError +from druks.apps.loader import iter_apps, load from druks.workflows import Subject from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient class Widget(Subject): - """What the fake extensions below are about. A workflow is what declares a subject - in a real extension; these stand in for that.""" + """What the fake apps below are about. A workflow is what declares a subject + in a real app; these stand in for that.""" @classmethod def list_summaries(cls, account_id: str | None) -> list: @@ -22,26 +22,26 @@ def _subjects(cls) -> list[type[Subject]]: return [Widget] -def test_iter_extensions_discovers_the_bundled_extensions(): - """The bundled extensions resolve from the ``druks.extensions`` entry points.""" - assert {extension.name for extension in iter_extensions()} >= {"core", "ship", "usage"} +def test_iter_apps_discovers_the_bundled_apps(): + """The bundled apps resolve from the ``druks.apps`` entry points.""" + assert {app.name for app in iter_apps()} >= {"core", "ship", "usage"} -def test_platform_extensions_are_builtin(): +def test_platform_apps_are_builtin(): """``builtin`` is what keeps a platform surface out of the shell's app switcher.""" - builtin = {extension.name for extension in iter_extensions() if extension.builtin} + builtin = {app.name for app in iter_apps() if app.builtin} assert builtin >= {"core", "usage"} def test_ship_app_derives_its_package_from_the_defining_module(): - ship = next(extension for extension in iter_extensions() if extension.name == "ship") + ship = next(app for app in iter_apps() if app.name == "ship") assert ship.package == "druks.contrib.ship" -def test_extension_without_a_name_is_rejected(): +def test_app_without_a_name_is_rejected(): with pytest.raises(TypeError, match="must set a `name`"): - class Nameless(Extension): + class Nameless(App): pass @@ -49,12 +49,12 @@ def _fake_entry(name: str, value: object) -> SimpleNamespace: return SimpleNamespace(name=name, load=lambda: value) -def test_duplicate_extension_name_is_rejected(monkeypatch): - class DupA(Extension): +def test_duplicate_app_name_is_rejected(monkeypatch): + class DupA(App): name = "dup" package = "a" - class DupB(Extension): + class DupB(App): name = "dup" package = "b" @@ -63,23 +63,23 @@ class DupB(Extension): "entry_points", lambda *, group: [_fake_entry("dup", DupA), _fake_entry("dup", DupB)], ) - with pytest.raises(ValueError, match="duplicate extension name"): - iter_extensions() + with pytest.raises(ValueError, match="duplicate app name"): + iter_apps() -def test_entry_point_resolving_to_a_non_extension_is_rejected(monkeypatch): +def test_entry_point_resolving_to_a_non_app_is_rejected(monkeypatch): monkeypatch.setattr( loader, "entry_points", lambda *, group: [_fake_entry("bad", object())], ) - with pytest.raises(TypeError, match="not an Extension"): - iter_extensions() + with pytest.raises(TypeError, match="not an App"): + iter_apps() -def test_load_confines_extension_routers_to_the_extension_namespace(monkeypatch): +def test_load_confines_app_routers_to_the_app_namespace(monkeypatch): """A router declaring a prefix that would shadow the platform still lands - under the injected ``/api/`` — extensions can't escape their namespace.""" + under the injected ``/api/`` — apps can't escape their namespace.""" rogue = APIRouter(prefix="/health") # tries to shadow the platform health check @rogue.get("/ping") @@ -89,7 +89,7 @@ def _ping() -> dict: routes_module = ModuleType("evil.routes") routes_module.__dict__["router"] = rogue - class EvilExtension(Extension): + class EvilApp(App): name = "evil" package = "evil" @@ -97,9 +97,9 @@ class EvilExtension(Extension): def discover(cls) -> list[ModuleType]: return [routes_module] - monkeypatch.setattr(loader, "iter_extensions", lambda: [EvilExtension]) + monkeypatch.setattr(loader, "iter_apps", lambda: [EvilApp]) # The fake's package isn't importable; the prefix check is not under test here. - monkeypatch.setattr(loader, "import_extension_models", lambda: None) + monkeypatch.setattr(loader, "import_app_models", lambda: None) app = FastAPI() load(app) # Mounting is under test, not the identity gate. @@ -120,27 +120,27 @@ def _routes_module(name: str, **routers: APIRouter) -> ModuleType: return module -def _boot(extension: type[Extension], monkeypatch) -> TestClient: - monkeypatch.setattr(loader, "iter_extensions", lambda: [extension]) - monkeypatch.setattr(loader, "import_extension_models", lambda: None) - app = FastAPI() - load(app) +def _boot(registered_app: type[App], monkeypatch) -> TestClient: + monkeypatch.setattr(loader, "iter_apps", lambda: [registered_app]) + monkeypatch.setattr(loader, "import_app_models", lambda: None) + api = FastAPI() + load(api) from druks.accounts.dependencies import current_account - app.dependency_overrides[current_account] = lambda: None - return TestClient(app) + api.dependency_overrides[current_account] = lambda: None + return TestClient(api) -def test_nothing_an_extension_declares_can_take_a_read_the_platform_serves(monkeypatch): +def test_nothing_an_app_declares_can_take_a_read_the_platform_serves(monkeypatch): """Not even a catch-all: the platform's two segments are matched before any router - an extension declares, so an author never has to know they are reserved.""" + an app declares, so an author never has to know they are reserved.""" greedy = APIRouter() @greedy.get("/{anything:path}") def _greedy(anything: str) -> dict: - return {"who": "extension"} + return {"who": "app"} - class Greedy(Extension): + class Greedy(App): name = "greedy" package = "greedy" subjects = classmethod(_subjects) @@ -151,12 +151,12 @@ def discover(cls) -> list[ModuleType]: client = _boot(Greedy, monkeypatch) assert client.get("/api/greedy/widget").json() == {"rows": []} - assert client.get("/api/greedy/anything-else").json() == {"who": "extension"} + assert client.get("/api/greedy/anything-else").json() == {"who": "app"} def test_a_composed_router_loads(monkeypatch): """``parent.include_router(child)`` leaves a lazily-flattened entry behind, and an - extension that composes its routes is an ordinary one.""" + app that composes its routes is an ordinary one.""" parent, child = APIRouter(prefix="/parts"), APIRouter(prefix="/nested") @child.get("") @@ -165,7 +165,7 @@ def _nested() -> dict: parent.include_router(child) - class Composed(Extension): + class Composed(App): name = "composed" package = "composed" subjects = classmethod(_subjects) @@ -180,13 +180,13 @@ def discover(cls) -> list[ModuleType]: def test_a_subject_cannot_take_the_transcripts_segment(): - """Every extension's agent-call reads live there, so the collision is between two + """Every app's agent-call reads live there, so the collision is between two platform surfaces — an author would never see which one answered.""" class Transcripts(Subject): pass - class Colliding(Extension): + class Colliding(App): name = "colliding" package = "colliding" @@ -194,7 +194,7 @@ class Colliding(Extension): def workflows(cls): return [SimpleNamespace(subject=Transcripts)] - with pytest.raises(ExtensionSubjectContractError, match="agent-call reads"): + with pytest.raises(AppSubjectContractError, match="agent-call reads"): Colliding.subjects() @@ -202,7 +202,7 @@ def test_a_declared_subject_missing_list_summaries_is_rejected_with_an_actionabl class Account(Subject): pass # inherits the platform stub - class Broken(Extension): + class Broken(App): name = "broken" package = "broken" @@ -210,22 +210,22 @@ class Broken(Extension): def workflows(cls): return [SimpleNamespace(subject=Account)] - with pytest.raises(ExtensionSubjectContractError) as caught: + with pytest.raises(AppSubjectContractError) as caught: Broken.subjects() message = str(caught.value) - assert "broken" in message # the extension name + assert "broken" in message # the app name assert "Account" in message # the subject class assert "list_summaries()" in message # the missing method assert "Implement list_summaries()" in message # the implementation direction def test_full_boot_refuses_a_subject_missing_list_summaries(monkeypatch): - """An extension with nothing to mount still fails — the gate is a loader stage.""" + """An app with nothing to mount still fails — the gate is a loader stage.""" class Account(Subject): pass # inherits the platform stub - class Broken(Extension): + class Broken(App): name = "broken_boot" package = "broken_boot" @@ -237,9 +237,9 @@ def discover(cls) -> list[ModuleType]: def workflows(cls): return [SimpleNamespace(subject=Account)] - monkeypatch.setattr(loader, "iter_extensions", lambda: [Broken]) - monkeypatch.setattr(loader, "import_extension_models", lambda: None) - with pytest.raises(ExtensionSubjectContractError, match="list_summaries"): + monkeypatch.setattr(loader, "iter_apps", lambda: [Broken]) + monkeypatch.setattr(loader, "import_app_models", lambda: None) + with pytest.raises(AppSubjectContractError, match="list_summaries"): load(FastAPI()) @@ -252,7 +252,7 @@ def list_summaries(cls, account_id: str | None) -> list: class Inheritor(Concrete): pass - class Fine(Extension): + class Fine(App): name = "fine" package = "fine" @@ -263,24 +263,24 @@ def workflows(cls): assert Fine.subjects() == [Inheritor] -def test_an_extension_declaring_a_subject_type_is_rejected(): - """The workflow says what its runs are about; an extension repeating it would be a +def test_an_app_declaring_a_subject_type_is_rejected(): + """The workflow says what its runs are about; an app repeating it would be a second spelling that can go stale.""" with pytest.raises(TypeError, match="declares subject_type"): - class Stale(Extension): + class Stale(App): name = "stale" subject_type = "widget" -def test_an_extensions_subjects_come_from_its_workflows(): - ship = next(extension for extension in iter_extensions() if extension.name == "ship") +def test_an_apps_subjects_come_from_its_workflows(): + ship = next(app for app in iter_apps() if app.name == "ship") ship.discover() assert [subject.subject_type for subject in ship.subjects()] == ["project_repo", "work_item"] -def test_the_extension_name_tags_every_route(monkeypatch): +def test_the_app_name_tags_every_route(monkeypatch): """An author writes what their router serves; the platform says whose it is.""" router = APIRouter(prefix="/parts") @@ -288,7 +288,7 @@ def test_the_extension_name_tags_every_route(monkeypatch): def _parts() -> dict: return {} - class Tagged(Extension): + class Tagged(App): name = "tagged" package = "tagged" @@ -296,8 +296,8 @@ class Tagged(Extension): def discover(cls) -> list[ModuleType]: return [_routes_module("tagged", router=router)] - monkeypatch.setattr(loader, "iter_extensions", lambda: [Tagged]) - monkeypatch.setattr(loader, "import_extension_models", lambda: None) + monkeypatch.setattr(loader, "iter_apps", lambda: [Tagged]) + monkeypatch.setattr(loader, "import_app_models", lambda: None) app = FastAPI() load(app) assert app.openapi()["paths"]["/api/tagged/parts"]["get"]["tags"] == ["tagged"] diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index f5bdd276..d7bcfc0a 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -5,7 +5,7 @@ current_session_account, current_session_or_setup, ) -from druks.api.app import app +from druks.api.server import app from fastapi.routing import APIRoute, _IncludedRouter # Every /api path allowed to skip the identity gate; additions are deliberate. @@ -28,7 +28,7 @@ ("POST", "/api/auth/personal-tokens"), ("DELETE", "/api/auth/personal-tokens/{pat_id}"), ("DELETE", "/api/harnesses/{name}/connection"), - ("PATCH", "/api/settings/extensions"), + ("PATCH", "/api/settings/apps"), ("POST", "/api/services/{slug}"), ("GET", "/api/oauth/{slug}/connect"), ("GET", "/api/oauth/connections"), @@ -38,7 +38,7 @@ # Session-gated routes that also sit behind their router's identity gate — # the route-level session dependency is the stricter of the two. DUAL_GATED_API_PATHS = { - "/api/settings/extensions", + "/api/settings/apps", "/api/services/{slug}", "/api/oauth/{slug}/connect", "/api/oauth/connections", diff --git a/backend/tests/test_auth_pats.py b/backend/tests/test_auth_pats.py index 05bdedfc..5ce534d4 100644 --- a/backend/tests/test_auth_pats.py +++ b/backend/tests/test_auth_pats.py @@ -185,15 +185,15 @@ def test_a_pat_cannot_disconnect_a_harness(tmp_path, druks_db): assert beside.status_code == 401 -def test_a_pat_reads_but_cannot_write_extension_settings(tmp_path, druks_db): +def test_a_pat_reads_but_cannot_write_app_settings(tmp_path, druks_db): with _client(tmp_path) as client: _, token = _mint("op@example.com") headers = _bearer(token) - read = client.get("/api/settings/extensions", headers=headers) + read = client.get("/api/settings/apps", headers=headers) write = client.patch( - "/api/settings/extensions", - json={"extensionSettings": {"ship": {"linear_trigger_status": "Agent Queue"}}}, + "/api/settings/apps", + json={"appSettings": {"ship": {"linear_trigger_status": "Agent Queue"}}}, headers=headers, ) diff --git a/backend/tests/test_author_surface.py b/backend/tests/test_author_surface.py index ca751f8e..a71cdded 100644 --- a/backend/tests/test_author_surface.py +++ b/backend/tests/test_author_surface.py @@ -2,10 +2,10 @@ import pytest -# The documented v1 extension-author surface: each concern namespace and the +# The documented v1 app-author surface: each concern namespace and the # exact names it exports. Pattern A — no root facade; druks stays thin. AUTHOR_SURFACE = { - "druks.extensions": {"Extension", "ExtensionSettings", "Secret"}, + "druks.apps": {"App", "AppSettings", "Secret"}, "druks.browser": {"BrowserSession", "BrowserSessionSignedOutError"}, "druks.services": { "OauthClient", diff --git a/backend/tests/test_browser_borrow.py b/backend/tests/test_browser_borrow.py index 1201fe22..3eeee689 100644 --- a/backend/tests/test_browser_borrow.py +++ b/backend/tests/test_browser_borrow.py @@ -115,7 +115,7 @@ def stored_session( return row -def test_declaration_carries_the_extension_namespace(night_watch): +def test_declaration_carries_the_app_namespace(night_watch): assert night_watch.acme.name == "night_watch.acme" assert night_watch.docs.name == "night_watch.docs" @@ -219,7 +219,7 @@ async def test_launch_failure_raises_and_releases_the_lock(borrow, night_watch): async def test_signed_out_borrow_stamps_the_session_and_stores_nothing(borrow, night_watch): - """The extension raises through the borrow when the site bounced the login: + """The app raises through the borrow when the site bounced the login: the door stamps which session bounced, and the dead state is never stored.""" browser, redis = borrow stored_session(night_watch.acme, payload=b"live-state") diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index f7f1395f..c5b4a9b4 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -180,7 +180,7 @@ def test_run_checks_covers_all_check_names(tmp_path: Path) -> None: results = doctor.run_checks(settings) - # Installed extensions contribute their own checks alongside the platform's. + # Installed apps contribute their own checks alongside the platform's. assert {result.name for result in results} >= { "webhook_ingress", "github_identity", diff --git a/backend/tests/test_doctor_capability_modules.py b/backend/tests/test_doctor_capability_modules.py index b28c5d69..d5792cbc 100644 --- a/backend/tests/test_doctor_capability_modules.py +++ b/backend/tests/test_doctor_capability_modules.py @@ -39,10 +39,10 @@ def _temp_capability_package(tmp_path: Path, monkeypatch, *, module_name: str) - for name in list(sys.modules): if name == package or name.startswith(f"{package}."): del sys.modules[name] - # The check reads packages off iter_extensions at call time; the walk only + # The check reads packages off iter_apps at call time; the walk only # needs an object with a ``package``. monkeypatch.setattr( - "druks.doctor.iter_extensions", + "druks.doctor.iter_apps", lambda: [SimpleNamespace(package=package)], ) return package @@ -89,7 +89,7 @@ def test_capability_re_exported_by_a_role_module_is_not_flagged( if name == package or name.startswith(f"{package}."): del sys.modules[name] monkeypatch.setattr( - "druks.doctor.iter_extensions", + "druks.doctor.iter_apps", lambda: [SimpleNamespace(package=package)], ) settings = make_settings(tmp_path) diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index e3144e67..a4c5403a 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -6,11 +6,11 @@ import psycopg import pytest from druks.agents import Agent, AgentOutput +from druks.apps.registry import agents, workflows from druks.database import configure_session, get_session from druks.durable import FatalError, Run, RunState from druks.durable.dbos_state import workflow_status from druks.durable.engine import configure_engine, init_dbos, launch, shutdown -from druks.extensions.registry import agents, workflows from druks.models import StoredSubject from druks.testing import init_db from druks.workflows import Gate, Subject, Workflow, step @@ -978,7 +978,7 @@ async def test_subject_reaches_body_and_result_rides_finished_event(rt): async def test_registry_rejects_duplicate_key(rt): # Re-registering the same item is idempotent, but a different capability on an # existing key is a collision (raises) — two can't share a durable identity. - from druks.extensions.registry import Registry + from druks.apps.registry import Registry registry = Registry("test", key=lambda entry: entry["kind"]) capability = {"kind": "k"} diff --git a/backend/tests/test_events_feed.py b/backend/tests/test_events_feed.py index 2cfc304d..b7d56041 100644 --- a/backend/tests/test_events_feed.py +++ b/backend/tests/test_events_feed.py @@ -22,16 +22,16 @@ def test_feed_carries_what_a_row_is_worded_from(druks_db): type="workflow.running", subject=note.identity, label=note.label, - extension="field_notes", + app="field_notes", payload={"kind": Summarize.kind, "run": "wf1"}, ) - Event.emit(type="summarized", subject=note.identity, label=note.label, extension="field_notes") + Event.emit(type="summarized", subject=note.identity, label=note.label, app="field_notes") druks_db.flush() by_kind = {row.kind: row for row in build_feed()[0]} started = by_kind["workflow.running"] - assert (started.extension, started.workflow) == ("field_notes", Summarize.kind) + assert (started.app, started.workflow) == ("field_notes", Summarize.kind) assert (started.subject_type, started.subject_id) == ("note", str(note.id)) # A note declares no label of its own, so it shows itself by identity. assert started.subject_label == f"note {note.id}" @@ -48,13 +48,11 @@ def test_every_subject_shows_itself(druks_db): druks_db.flush() assert crate.identity == {"type": "crate", "id": 7} for subject in (crate, pallet): - Event.emit( - type="stocked", subject=subject.identity, label=subject.label, extension="faketest" - ) + Event.emit(type="stocked", subject=subject.identity, label=subject.label, app="faketest") druks_db.delete(crate) druks_db.flush() - by_type = {row.subject_type: row for row in build_feed()[0] if row.extension == "faketest"} + by_type = {row.subject_type: row for row in build_feed()[0] if row.app == "faketest"} assert by_type["crate"].subject_label == "CRATE-7" assert by_type["pallet"].subject_label == "pallet 7" diff --git a/backend/tests/test_generic_subjects.py b/backend/tests/test_generic_subjects.py index 3b1005fd..1b318b71 100644 --- a/backend/tests/test_generic_subjects.py +++ b/backend/tests/test_generic_subjects.py @@ -2,11 +2,11 @@ import pytest from druks.accounts.context import current_account_id +from druks.apps.base import App from druks.database import db_session from druks.durable import AgentCall, Run from druks.durable.datastructures import Subject from druks.durable.schemas import SubjectSummary -from druks.extensions.base import Extension from druks.models import StoredSubject from druks.testing import seed_dbos_status from fastapi import APIRouter @@ -64,7 +64,7 @@ def list_summaries(cls, account_id: str | None) -> list[_ThingSummary]: return [] -class _ThingExtension(Extension): +class _ThingApp(App): name = "faketest" @@ -103,8 +103,8 @@ def _seed_call(session, run, *, agent, status="succeeded"): @pytest.fixture def client(tmp_path: Path, druks_db, monkeypatch): - # The real app mounts every extension's routers before its catch-all 404, so the - # fake extension's router has to slot in there too — appending lands after the + # The real app mounts every app's routers before its catch-all 404, so the + # fake app's router has to slot in there too — appending lands after the # catch-all and gets shadowed. Pulled back out on teardown; the app is a singleton. from druks.testing import configure_app_for_test, make_settings @@ -116,9 +116,7 @@ def client(tmp_path: Path, druks_db, monkeypatch): holder = APIRouter() for subject_class in (Thing, Ticket): - holder.include_router( - _ThingExtension._get_subject_routes(subject_class), prefix="/api/faketest" - ) + holder.include_router(_ThingApp._get_subject_routes(subject_class), prefix="/api/faketest") catchall = next( i for i, r in enumerate(app.routes) if getattr(r, "path", "") == "/api/{path:path}" ) @@ -246,7 +244,7 @@ async def test_the_board_and_its_stream_hand_the_caller_to_list_summaries(druks_ # The route reads the caller at handler entry and passes it down as data. # The stream keeps that caller after the request context ends. endpoints = { - route.path: route.endpoint for route in _ThingExtension._get_subject_routes(Inbox).routes + route.path: route.endpoint for route in _ThingApp._get_subject_routes(Inbox).routes } CALLERS.clear() @@ -298,7 +296,7 @@ def test_a_subjects_stream_wins_over_the_greedy_id_matcher(client: TestClient, d def test_the_literal_routes_are_declared_ahead_of_the_id_matcher(subject_class): # FastAPI matches in declaration order; both boards would be unreachable if the # id matcher came first. - router = _ThingExtension._get_subject_routes(subject_class) + router = _ThingApp._get_subject_routes(subject_class) prefix = f"/{subject_class.subject_type}" assert [route.path for route in router.routes] == [ diff --git a/backend/tests/test_github.py b/backend/tests/test_github.py index 3db81cab..39962095 100644 --- a/backend/tests/test_github.py +++ b/backend/tests/test_github.py @@ -502,7 +502,7 @@ async def test_github_client_aclose_drops_cache_without_raising() -> None: async def test_get_file_content_returns_none_when_repo_missing() -> None: - """A 404 at the installation lookup (the repo doesn't exist / the Extension + """A 404 at the installation lookup (the repo doesn't exist / the App isn't installed) means the file doesn't exist either — so get_file_content returns None rather than raising. This is the prompt-override hierarchy probing a missing ``/.druks`` repo: it must fall through to the diff --git a/backend/tests/test_mcp_endpoint.py b/backend/tests/test_mcp_endpoint.py index 52b9bc38..e8da82d6 100644 --- a/backend/tests/test_mcp_endpoint.py +++ b/backend/tests/test_mcp_endpoint.py @@ -7,12 +7,12 @@ import pytest from conftest import finish_agent_run, make_test_note, seed_note_agent_run, seed_note_run from druks.accounts.models import Account, PersonalAccessToken -from druks.api.app import mcp_app -from druks.contrib.ship.extension import Ship +from druks.api.server import mcp_app +from druks.contrib.ship.app import Ship from druks.core.apis.exceptions import UnknownTicketError from druks.durable.models import Artifact, Run -from druks.mcp.app import create_mcp_app from druks.mcp.exceptions import InvalidAgentToolError +from druks.mcp.server import create_mcp_app from druks.testing import configure_app_for_test, make_settings from druks.usage.models import UsageScrape from fastapi import APIRouter, FastAPI @@ -158,7 +158,7 @@ async def test_mcp_subpaths_never_reach_the_spa(app): assert stray.headers["content-type"].startswith("application/json") -async def test_tools_list_pins_platform_and_extension_tools(app, pat_token): +async def test_tools_list_pins_platform_and_app_tools(app, pat_token): async with live(app), _client(app, pat_token) as client: assert "list_open_subjects" in (client.initialize_result.instructions or "") assert "parkedAt" in (client.initialize_result.instructions or "") @@ -186,13 +186,13 @@ async def test_tools_list_pins_platform_and_extension_tools(app, pat_token): assert tools[name].description for name in ("review_request", "ship_start"): - extension_tool = tools[name] + app_tool = tools[name] assert ( - extension_tool.annotations.readOnlyHint, - extension_tool.annotations.destructiveHint, - extension_tool.annotations.idempotentHint, + app_tool.annotations.readOnlyHint, + app_tool.annotations.destructiveHint, + app_tool.annotations.idempotentHint, ) == (False, True, False) - assert extension_tool.description + assert app_tool.description # Derived schemas keep the routes' own shapes and constraints. assert tools["answer_gate"].inputSchema["required"] == ["run", "parkedAt", "control"] @@ -220,7 +220,7 @@ async def test_tools_list_pins_platform_and_extension_tools(app, pat_token): ("review_start", None, "docstring"), ], ) -def test_invalid_extension_agent_route_stops_boot(operation_id, docstring, message): +def test_invalid_app_agent_route_stops_boot(operation_id, docstring, message): api = FastAPI() router = APIRouter() @@ -242,7 +242,7 @@ async def endpoint(): def test_derived_operation_id_collision_stops_boot(): - # The 'review' extension's unprefixed 'scan' derives to 'review_scan', which + # The 'review' app's unprefixed 'scan' derives to 'review_scan', which # another route already claims explicitly — the framework must reject the # clash rather than silently mint two operations sharing an id. api = FastAPI() @@ -265,7 +265,7 @@ async def review_scan(): def _agent_route_app(operation_id: str) -> FastAPI: - # A synthetic agent route owned by the installed 'review' extension — the + # A synthetic agent route owned by the installed 'review' app — the # loader-stamped 'review' tag names the owner, exactly as a real router does. # The endpoint mounts the same /mcp Route and mcp lifespan as the real app so # tools/list resolves in-process. @@ -306,7 +306,7 @@ def _served_operation_id(schema: dict, path: str) -> str: ("review_scan", "review_scan"), # an already-prefixed id passes through, never doubled ], ) -async def test_extension_agent_route_derives_the_namespaced_tool( +async def test_app_agent_route_derives_the_namespaced_tool( druks_db, pat_token, operation_id, expected ): api = _agent_route_app(operation_id) diff --git a/backend/tests/test_mcp_oauth.py b/backend/tests/test_mcp_oauth.py index 6c0b1c29..29ad3c84 100644 --- a/backend/tests/test_mcp_oauth.py +++ b/backend/tests/test_mcp_oauth.py @@ -9,7 +9,7 @@ import pytest from druks.accounts.constants import SYSTEM_ACCOUNT_ID from druks.accounts.models import Account -from druks.extensions.registry import mcp_servers +from druks.apps.registry import mcp_servers from druks.mcp import oauth from druks.mcp.enums import IdentityMode, TokenSource from druks.mcp.exceptions import ( diff --git a/backend/tests/test_mcp_servers.py b/backend/tests/test_mcp_servers.py index 9887f0aa..9e544ede 100644 --- a/backend/tests/test_mcp_servers.py +++ b/backend/tests/test_mcp_servers.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from druks.extensions.registry import mcp_servers +from druks.apps.registry import mcp_servers from druks.harnesses.claude import ClaudeHarness from druks.harnesses.codex import CodexHarness from druks.harnesses.datastructures import SandboxSettings diff --git a/backend/tests/test_notifications_durable.py b/backend/tests/test_notifications_durable.py index 6554f29b..84f8c99b 100644 --- a/backend/tests/test_notifications_durable.py +++ b/backend/tests/test_notifications_durable.py @@ -5,10 +5,10 @@ import psycopg import pytest from dbos import DBOS +from druks.apps.registry import workflows from druks.database import configure_session, db_session, get_session from druks.durable.engine import configure_engine, init_dbos, launch, shutdown from druks.durable.enums import RunState -from druks.extensions.registry import workflows from druks.models import StoredSubject from druks.notifications import outbox from druks.notifications.exceptions import DeliveryError, NotificationError diff --git a/backend/tests/test_proof_app.py b/backend/tests/test_proof_app.py new file mode 100644 index 00000000..b808751d --- /dev/null +++ b/backend/tests/test_proof_app.py @@ -0,0 +1,33 @@ +from druks.apps.loader import load_app +from druks.models import Base + +_PACKAGE = "druks_field_notes" + + +def test_boot_loads_the_external_app(): + app = load_app("field_notes") + + assert app.name == "field_notes" + assert app.package == _PACKAGE + assert [subject.__name__ for subject in app.subjects()] == ["Note"] + + +def test_discovery_registers_the_tables_and_capabilities(): + app = load_app("field_notes") + + assert "field_notes_notes" in Base.metadata.tables + assert [workflow.__name__ for workflow in app.workflows()] == ["Summarize"] + + capability_modules = {module.__name__ for module in app.capability_modules()} + assert f"{_PACKAGE}.subscribers" in capability_modules + prefixes = {router.prefix for router in app.routers()} + assert prefixes >= {"/notes", "/transcripts/{call_id}", "/note"} + + +def test_migration_is_the_history_root(): + app = load_app("field_notes") + + package_dir = app.package_dir() + assert app.migrations_dir() == package_dir / "migrations" + (baseline,) = (package_dir / "migrations" / "versions").glob("*.py") + assert baseline.name.startswith("field_notes_") diff --git a/backend/tests/test_proof_extension_install.py b/backend/tests/test_proof_app_install.py similarity index 60% rename from backend/tests/test_proof_extension_install.py rename to backend/tests/test_proof_app_install.py index 1b659681..e8fe2ef8 100644 --- a/backend/tests/test_proof_extension_install.py +++ b/backend/tests/test_proof_app_install.py @@ -6,23 +6,23 @@ # already cover the settings-value and migration reads that need one. _CHECK = textwrap.dedent( """ - from druks.extensions.loader import iter_extensions, load_extension + from druks.apps.loader import iter_apps, load_app - names = {extension.name for extension in iter_extensions()} + names = {app.name for app in iter_apps()} assert "field_notes" in names, names - extension = load_extension("field_notes") - assert [subject.__name__ for subject in extension.subjects()] == ["Note"] - assert extension.settings_model is not None - assert list(extension.settings_model.model_fields) == [ + app = load_app("field_notes") + assert [subject.__name__ for subject in app.subjects()] == ["Note"] + assert app.settings_model is not None + assert list(app.settings_model.model_fields) == [ "board_size", "visibility", "sync_signing_key", "sync_token", ] - assert [workflow.__name__ for workflow in extension.workflows()] == ["Summarize"] - assert {router.prefix for router in extension.routers()} >= {"/notes", "/note"} - assert extension.migrations_dir() is not None + assert [workflow.__name__ for workflow in app.workflows()] == ["Summarize"] + assert {router.prefix for router in app.routers()} >= {"/notes", "/note"} + assert app.migrations_dir() is not None print("ok") """ ) diff --git a/backend/tests/test_proof_extension_migration.py b/backend/tests/test_proof_app_migration.py similarity index 92% rename from backend/tests/test_proof_extension_migration.py rename to backend/tests/test_proof_app_migration.py index da0bb086..cf7ef779 100644 --- a/backend/tests/test_proof_extension_migration.py +++ b/backend/tests/test_proof_app_migration.py @@ -5,8 +5,8 @@ from druks.testing import TEST_DATABASE_URL, init_db from sqlalchemy import create_engine -# The proof extension's real, shipped migration, run through the shared platform env -# exactly as ``druks init-db`` runs an installed extension's — from the package's own +# The proof app's real, shipped migration, run through the shared platform env +# exactly as ``druks init-db`` runs an installed app's — from the package's own # ``version_locations`` under its own ``alembic_version_field_notes`` history. Proves an # out-of-tree package's hand-written baseline applies cleanly and tracks its own head, # independent of core's. Manages its own DDL (via an AUTOCOMMIT engine) and cleans up, so diff --git a/backend/tests/test_proof_extension.py b/backend/tests/test_proof_extension.py deleted file mode 100644 index 36dc078e..00000000 --- a/backend/tests/test_proof_extension.py +++ /dev/null @@ -1,33 +0,0 @@ -from druks.extensions.loader import load_extension -from druks.models import Base - -_PACKAGE = "druks_field_notes" - - -def test_boot_loads_the_external_extension(): - extension = load_extension("field_notes") - - assert extension.name == "field_notes" - assert extension.package == _PACKAGE - assert [subject.__name__ for subject in extension.subjects()] == ["Note"] - - -def test_discovery_registers_the_tables_and_capabilities(): - extension = load_extension("field_notes") - - assert "field_notes_notes" in Base.metadata.tables - assert [workflow.__name__ for workflow in extension.workflows()] == ["Summarize"] - - capability_modules = {module.__name__ for module in extension.capability_modules()} - assert f"{_PACKAGE}.subscribers" in capability_modules - prefixes = {router.prefix for router in extension.routers()} - assert prefixes >= {"/notes", "/transcripts/{call_id}", "/note"} - - -def test_migration_is_the_history_root(): - extension = load_extension("field_notes") - - package_dir = extension.package_dir() - assert extension.migrations_dir() == package_dir / "migrations" - (baseline,) = (package_dir / "migrations" / "versions").glob("*.py") - assert baseline.name.startswith("field_notes_") diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index e5289a7f..f0d86ef2 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -2,12 +2,12 @@ from types import SimpleNamespace import pytest +from druks.apps.settings import field_kind, field_multiline from druks.contrib.review import subscribers # noqa: F401 — the import registers it +from druks.contrib.review.app import Review, check_review_identity from druks.contrib.review.datastructures import PullRequest -from druks.contrib.review.extension import Review, check_review_identity from druks.contrib.review.github import get_review_actor from druks.contrib.review.workflows import PullRequestReview -from druks.extensions.settings import field_kind, field_multiline from druks.prompts import render_prompt from druks.services.exceptions import ServiceNotConnectedError from druks.services.models import ServiceIdentity @@ -51,7 +51,7 @@ def test_a_pull_request_heads_its_own_page(): def test_the_pull_request_board_and_page_mount(client: TestClient, druks_db): - # PullRequestReview declares PullRequest, so the extension mounts its board and + # PullRequestReview declares PullRequest, so the app mounts its board and # page — keyed by a handle that carries both a path separator and a `#`. pull_request = PullRequest.get("acme/app", 7) seed_run(druks_db, kind=PullRequestReview.kind, subject=pull_request, state="running") @@ -139,7 +139,7 @@ def _connect_operator() -> None: def _set_review_setting(field: str, value: str) -> None: - SettingsOverride.set_extension_setting("review", field, value, is_secret=True) + SettingsOverride.set_app_setting("review", field, value, is_secret=True) def test_a_configured_review_identity_approves(druks_db): diff --git a/backend/tests/test_sandbox_runner.py b/backend/tests/test_sandbox_runner.py index 24fb4cc6..4b7e9475 100644 --- a/backend/tests/test_sandbox_runner.py +++ b/backend/tests/test_sandbox_runner.py @@ -68,7 +68,7 @@ async def stat(self, path: str) -> _FakeAttrs: raise _FakeSFTPNoSuchFile() return _FakeAttrs(size=len(self.vm.files[path])) - def open(self, path: str, extension: str = "rb") -> _FakeFileHandle: + def open(self, path: str, app: str = "rb") -> _FakeFileHandle: # asyncssh returns an awaitable that resolves to a file handle # supporting async context manager + async read. We collapse # that to a directly-returned object because the runner uses diff --git a/backend/tests/test_scaffolding.py b/backend/tests/test_scaffolding.py index 41ee802f..f8745e7f 100644 --- a/backend/tests/test_scaffolding.py +++ b/backend/tests/test_scaffolding.py @@ -2,14 +2,14 @@ import sys import pytest -from druks.extensions.loader import _workflow_packages, mount, register_workflow_package -from druks.scaffolding import create_extension +from druks.apps.loader import _workflow_packages, mount, register_workflow_package +from druks.scaffolding import create_app from fastapi import FastAPI from fastapi.testclient import TestClient -def test_create_extension_scaffolds_a_loadable_package(tmp_path): - target = create_extension("night_watch", tmp_path) +def test_create_app_scaffolds_a_loadable_package(tmp_path): + target = create_app("night_watch", tmp_path) assert target == tmp_path / "druks-night_watch" package = target / "druks_night_watch" @@ -17,12 +17,12 @@ def test_create_extension_scaffolds_a_loadable_package(tmp_path): rendered = [path for path in target.rglob("*") if path.is_file()] assert rendered # The generated suite is what an author runs first; the scaffold is useless without it. - assert (target / "tests" / "test_extension.py").is_file() + assert (target / "tests" / "test_app.py").is_file() for path in rendered: assert "-tpl" not in path.name assert "{{" not in path.read_text() assert ( - 'night_watch = "druks_night_watch.extension:NightWatch"' + 'night_watch = "druks_night_watch.app:NightWatch"' in (target / "pyproject.toml").read_text() ) @@ -38,35 +38,35 @@ def test_create_extension_scaffolds_a_loadable_package(tmp_path): assert "subject_type" not in text assert "Subject(" not in text - # The generated extension.py must survive Extension.__init_subclass__ validation, + # The generated app.py must survive App.__init_subclass__ validation, # and mounting must serve both the API routes and the shipped dist/ frontend. sys.path.insert(0, str(target)) try: - module = importlib.import_module("druks_night_watch.extension") - extension = module.NightWatch - assert extension.name == "night_watch" - assert extension.table_prefix == "night_watch_" - assert extension.package == "druks_night_watch" + module = importlib.import_module("druks_night_watch.app") + night_watch = module.NightWatch + assert night_watch.name == "night_watch" + assert night_watch.table_prefix == "night_watch_" + assert night_watch.package == "druks_night_watch" - # An installed extension has its package claimed by the loader before any + # An installed app has its package claimed by the loader before any # module imports; the generated workflow resolves its identity from that. - register_workflow_package(extension.package, extension.name) + register_workflow_package(night_watch.package, night_watch.name) for role in ("models", "schemas", "contracts", "workflows", "routes", "subscribers"): importlib.import_module(f"druks_night_watch.{role}") - # The workflow guidance must not teach a per-run extension= argument — - # a workflow's identity comes from its declaring extension. - assert "extension=" not in (target / "druks_night_watch" / "workflows.py").read_text() + # The workflow guidance must not teach a per-run app= argument — + # a workflow's identity comes from its declaring app. + assert "app=" not in (target / "druks_night_watch" / "workflows.py").read_text() - app = FastAPI() - mount(app, extension, extension.discover()) + api = FastAPI() + mount(api, night_watch, night_watch.discover()) # Scaffolding is under test, not the identity gate. from druks.accounts.dependencies import current_account - app.dependency_overrides[current_account] = lambda: None - client = TestClient(app) - assert client.get("/api/night_watch/status").json() == {"extension": "night_watch"} + 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 @@ -78,11 +78,11 @@ def test_create_extension_scaffolds_a_loadable_package(tmp_path): del sys.modules[name] -def test_create_extension_rejects_bad_and_taken_names(tmp_path): +def test_create_app_rejects_bad_and_taken_names(tmp_path): with pytest.raises(ValueError, match="must match"): - create_extension("Night-Watch", tmp_path) + create_app("Night-Watch", tmp_path) with pytest.raises(ValueError, match="already installed"): - create_extension("ship", tmp_path) - create_extension("night_watch", tmp_path) + create_app("ship", tmp_path) + create_app("night_watch", tmp_path) with pytest.raises(ValueError, match="already exists"): - create_extension("night_watch", tmp_path) + create_app("night_watch", tmp_path) diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index b49610f6..96060291 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -380,7 +380,7 @@ async def fake_post(self, url, **kwargs): def declared_services(): # Service subclasses self-register at class definition; tests declare # inside this fixture and leave the registry as found. - from druks.extensions.registry import services + from druks.apps.registry import services saved = dict(services._items) yield @@ -456,7 +456,7 @@ class Settings(BaseModel): def test_abstract_base_shares_declarations_without_registering(declared_services): - from druks.extensions.registry import services + from druks.apps.registry import services from druks.services import Service from pydantic import BaseModel, SecretStr @@ -503,7 +503,7 @@ class Digest: name = "digest" acme = Acme.with_scopes("profile.read") - monkeypatch.setattr("druks.services.base.iter_extensions", lambda: [NightWatch, Digest]) + monkeypatch.setattr("druks.services.base.iter_apps", lambda: [NightWatch, Digest]) assert NightWatch.acme.scopes == ("profile.read", "posts.write") assert Acme.required_scopes() == ("posts.write", "profile.read") @@ -587,7 +587,7 @@ class NightWatch: name = "night_watch" acme = Acme.with_scopes("profile.read", "posts.write") - monkeypatch.setattr("druks.services.base.iter_extensions", lambda: [NightWatch]) + monkeypatch.setattr("druks.services.base.iter_apps", lambda: [NightWatch]) tokens = { "access_token": "at-1", "refresh_token": "rt-1", @@ -1146,7 +1146,7 @@ def entry(client, slug="acme"): assert connection["connectedAt"] -def test_next_lands_the_user_back_on_the_extension_page(tmp_path, acme, druks_db): +def test_next_lands_the_user_back_on_the_app_page(tmp_path, acme, druks_db): from urllib.parse import parse_qsl, urlparse from druks.testing import configure_app_for_test diff --git a/backend/tests/test_settings_overrides.py b/backend/tests/test_settings_overrides.py index 98d09e44..c804359e 100644 --- a/backend/tests/test_settings_overrides.py +++ b/backend/tests/test_settings_overrides.py @@ -5,15 +5,15 @@ # primitives with no API-level twin. -def test_extension_setting_override_then_default(druks_db): +def test_app_setting_override_then_default(druks_db): # No override → the declared default passed by the caller. - assert SettingsOverride.extension_setting("ship", "auto_merge", True, is_secret=False) is True + assert SettingsOverride.app_setting("ship", "auto_merge", True, is_secret=False) is True # An override wins — including turning it off. - SettingsOverride.set_extension_setting("ship", "auto_merge", False, is_secret=False) - assert SettingsOverride.extension_setting("ship", "auto_merge", True, is_secret=False) is False + SettingsOverride.set_app_setting("ship", "auto_merge", False, is_secret=False) + assert SettingsOverride.app_setting("ship", "auto_merge", True, is_secret=False) is False # Clearing reverts to the caller's default. - SettingsOverride.set_extension_setting("ship", "auto_merge", None, is_secret=False) - assert SettingsOverride.extension_setting("ship", "auto_merge", True, is_secret=False) is True + SettingsOverride.set_app_setting("ship", "auto_merge", None, is_secret=False) + assert SettingsOverride.app_setting("ship", "auto_merge", True, is_secret=False) is True def test_workflow_setting_namespaced_by_kind(druks_db): diff --git a/backend/tests/test_signals.py b/backend/tests/test_signals.py index 98257e8d..d3d6a128 100644 --- a/backend/tests/test_signals.py +++ b/backend/tests/test_signals.py @@ -1,5 +1,5 @@ import pytest -from druks.extensions.exceptions import SubscriberDeclarationError +from druks.apps.exceptions import SubscriberDeclarationError from druks.signals import publish, subscribe from druks_field_notes.models import Note from druks_field_notes.workflows import Summarize diff --git a/backend/tests/test_spa_serving.py b/backend/tests/test_spa_serving.py index 2bc99dbe..0f0957f5 100644 --- a/backend/tests/test_spa_serving.py +++ b/backend/tests/test_spa_serving.py @@ -1,6 +1,6 @@ from pathlib import Path -from druks.api.app import SpaCacheControl, _release_db_session, serve_spa +from druks.api.server import SpaCacheControl, _release_db_session, serve_spa from druks.database import db_session from fastapi import Depends, FastAPI, HTTPException from fastapi.testclient import TestClient diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index f08acd86..d7434b12 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -1,9 +1,9 @@ from typing import Literal import pytest -from druks.extensions import Extension, ExtensionSettings -from druks.extensions.exceptions import SettingsDeclarationError -from druks.extensions.settings import ( +from druks.apps import App, AppSettings +from druks.apps.exceptions import SettingsDeclarationError +from druks.apps.settings import ( coerce_setting_value, validate_setting_override, validate_settings_declaration, @@ -228,10 +228,10 @@ class _Settings(BaseModel): validate_settings_declaration(_Settings) -def test_extension_settings_must_subclass_extension_settings(): - with pytest.raises(SettingsDeclarationError, match="must subclass ExtensionSettings"): +def test_app_settings_must_subclass_app_settings(): + with pytest.raises(SettingsDeclarationError, match="must subclass AppSettings"): - class InvalidSettingsExtension(Extension): + class InvalidSettingsApp(App): name = "invalid_settings" class Settings(BaseModel): @@ -239,7 +239,7 @@ class Settings(BaseModel): def test_workflow_settings_remain_plain_base_models(): - assert not issubclass(Workflow.Settings, ExtensionSettings) + assert not issubclass(Workflow.Settings, AppSettings) validate_settings_declaration(Workflow.Settings) diff --git a/backend/tests/test_webhooks_framework.py b/backend/tests/test_webhooks_framework.py index ba1b175f..92a618a5 100644 --- a/backend/tests/test_webhooks_framework.py +++ b/backend/tests/test_webhooks_framework.py @@ -2,7 +2,7 @@ import hmac import pytest -from druks.extensions.registry import webhooks +from druks.apps.registry import webhooks from druks.settings import Settings from druks.webhooks import InvalidWebhookError, Webhook, verify_hmac_sha256 from druks.webhooks.router import match_webhook diff --git a/backend/tests/test_workflow_identity.py b/backend/tests/test_workflow_identity.py index f5c22f33..8f88a5ff 100644 --- a/backend/tests/test_workflow_identity.py +++ b/backend/tests/test_workflow_identity.py @@ -1,14 +1,14 @@ import pytest +from druks.apps import loader as apps_loader +from druks.apps.exceptions import MalformedApp +from druks.apps.loader import register_workflow_package, resolve_workflow_app +from druks.apps.registry import workflows from druks.core.workflows import RefreshTokens from druks.durable.enums import RunState from druks.durable.exceptions import WorkflowError from druks.durable.models import Run from druks.durable.schemas import get_display_label from druks.events.models import Event -from druks.extensions import loader as extensions_loader -from druks.extensions.exceptions import MalformedExtension -from druks.extensions.loader import register_workflow_package, resolve_workflow_extension -from druks.extensions.registry import workflows from druks.workflows import Gate, Workflow, _log_run_event, step from druks_field_notes.models import Note @@ -16,12 +16,12 @@ @pytest.fixture(autouse=True) def _isolated_registrations(): # Every test here mutates the package-owner map and the workflows registry; - # restore both so the suite keeps seeing only the installed extensions. - packages = dict(extensions_loader._workflow_packages) + # restore both so the suite keeps seeing only the installed apps. + packages = dict(apps_loader._workflow_packages) items = dict(workflows._items) yield - extensions_loader._workflow_packages.clear() - extensions_loader._workflow_packages.update(packages) + apps_loader._workflow_packages.clear() + apps_loader._workflow_packages.update(packages) workflows._items = items @@ -43,39 +43,39 @@ class Anonymous(Gate): def test_registration_is_idempotent_and_conflicts_loudly(): register_workflow_package("alpha_pkg", "alpha") register_workflow_package("alpha_pkg", "alpha") - with pytest.raises(MalformedExtension, match="already belongs"): + with pytest.raises(MalformedApp, match="already belongs"): register_workflow_package("alpha_pkg", "beta") def test_overlapping_ownership_across_owners_is_rejected(): register_workflow_package("alpha_pkg", "alpha") - with pytest.raises(MalformedExtension, match="overlaps"): + with pytest.raises(MalformedApp, match="overlaps"): register_workflow_package("alpha_pkg.nested", "beta") def test_resolution_matches_package_boundaries(): register_workflow_package("alpha_pkg", "alpha") - assert resolve_workflow_extension("alpha_pkg.workflows") == "alpha" + assert resolve_workflow_app("alpha_pkg.workflows") == "alpha" with pytest.raises(LookupError): - resolve_workflow_extension("alpha_pkg_sibling.workflows") + resolve_workflow_app("alpha_pkg_sibling.workflows") def test_unregistered_module_fails_at_class_definition(): # The error carries the invariant: load through the loader, or register first. - with pytest.raises(WorkflowError, match="druks.extensions.loader"): + with pytest.raises(WorkflowError, match="druks.apps.loader"): _workflow("Orphan", "nowhere.workflows") -def test_declaring_extension_namespaces_the_kind(): +def test_declaring_app_namespaces_the_kind(): register_workflow_package("alpha_pkg", "alpha") register_workflow_package("beta_pkg", "beta") alpha = _workflow("Summarize", "alpha_pkg.workflows") beta = _workflow("Summarize", "beta_pkg.workflows") - # Two extensions can share a local workflow name without colliding on kind. - assert (alpha.extension, alpha.kind) == ("alpha", "alpha.summarize") - assert (beta.extension, beta.kind) == ("beta", "beta.summarize") + # Two apps can share a local workflow name without colliding on kind. + assert (alpha.app, alpha.kind) == ("alpha", "alpha.summarize") + assert (beta.app, beta.kind) == ("beta", "beta.summarize") def test_explicit_kind_is_a_local_suffix(): @@ -93,7 +93,7 @@ def test_dotted_explicit_kind_is_rejected(): def test_none_owned_package_keeps_bare_kinds(): register_workflow_package("plain_pkg", None) flow = _workflow("Sweep", "plain_pkg.workflows") - assert flow.extension is None + assert flow.app is None assert flow.kind == "sweep" @@ -103,7 +103,7 @@ def test_in_tree_identities_are_stable(): assert workflows.get("ship.build") is not None assert workflows.get("ship.profile") is not None assert RefreshTokens.kind == "core.refresh_tokens" - assert (workflows.get("ship.build").extension, RefreshTokens.extension) == ("ship", "core") + assert (workflows.get("ship.build").app, RefreshTokens.app) == ("ship", "core") def test_steps_capture_the_namespaced_kind(): @@ -127,8 +127,8 @@ async def ping(self) -> None: ... assert "alpha.pinger" in captured -def test_lifecycle_event_stamps_the_declaring_extension(druks_db): - # The event's extension derives from the run's kind through the registry — +def test_lifecycle_event_stamps_the_declaring_app(druks_db): + # The event's app derives from the run's kind through the registry — # never an argument, never a stored copy on the run. register_workflow_package("alpha_pkg", "alpha") flow = _workflow("Beacon", "alpha_pkg.workflows") @@ -140,7 +140,7 @@ def test_lifecycle_event_stamps_the_declaring_extension(druks_db): event = druks_db.query(Event).filter_by(type="workflow.finished").one() assert payload["run"] == run.id - assert event.extension == "alpha" + assert event.app == "alpha" def test_display_label_reads_the_local_kind(): diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index ed3ad216..0cf8e1cd 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -51,8 +51,8 @@ http://:8000 { # Access: Cf-Access-Authenticated-User-Email, …); swapping the identity # edge is a config change, never a code change. # The backend serves everything behind the gate — the API, the SPA at /, - # extension frontends at /app/. Cache policy for the served - # frontends lives with their server (api/app.py), not at the edge. + # app frontends at /app/. Cache policy for the served + # frontends lives with their server (api/server.py), not at the edge. @dashboard_user header {$DRUKS_AUTH_HEADER} * handle @dashboard_user { reverse_proxy {$DRUKS_UPSTREAM} diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 768c952d..b4324cea 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -97,7 +97,7 @@ services: command: [ "uvicorn", - "druks.api.app:app", + "druks.api.server:app", "--host", "${DRUKS_WEB_BIND_HOST:-127.0.0.1}", "--port", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f84382f..471b95bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { Link, Route, Router, Switch, useLocation } from 'wouter' import { api } from './api/client' import { useScreenWakeLock } from './lib/useScreenWakeLock' import { EmptyState } from './components/EmptyState' -import { ExtensionDropdown } from './components/ExtensionDropdown' +import { AppDropdown } from './components/AppDropdown' import { Page } from './components/Page' import { SettingsModal } from './components/SettingsModal' import { UsagePill } from './components/UsagePill' @@ -13,10 +13,10 @@ import { EventsPage } from './pages/EventsPage' import { LoginWindowPage } from './pages/LoginWindowPage' import { SystemStrip } from './components/SystemStrip' import { UsagePage } from './pages/UsagePage' -import { extensionAccent } from './lib/extensionColors' -import './extensions' -import { registerInstalledExtensions } from './extensions/installed' -import { extensionHome, extensionOwning, getExtensionUI, registeredExtensions } from './extensions/registry' +import { appAccent } from './lib/appColors' +import './apps' +import { registerInstalledApps } from './apps/installed' +import { appHome, appOwning, getAppUI, registeredApps } from './apps/registry' // Vite's BASE_URL is normally '/'; wouter expects an empty base for the root. // Kept in sync with the Caddy SPA fallback so future relocations only need @@ -34,46 +34,46 @@ export function App() { function AppShell() { const [location, navigate] = useLocation() - // Every extension with a place in the shell, in registration order: the bundled + // Every app with a place in the shell, in registration order: the bundled // ones synchronously (routes, accent, and landing resolve on a cold load without - // any fetch), then the installed roster — each backend-only extension gets the + // any fetch), then the installed roster — each backend-only app gets the // generic pages, a dist-shipping one is mounted inside the shell by - // InstalledAppHost. Bundled first, roster extras A→Z. No extension name is + // InstalledAppHost. Bundled first, roster extras A→Z. No app name is // hardcoded. const rosterQuery = useQuery({ - queryKey: ['extensions'], - queryFn: api.listExtensions, + queryKey: ['apps'], + queryFn: api.listApps, staleTime: 60_000, }) const registered = useMemo(() => { - registerInstalledExtensions(rosterQuery.data) - return registeredExtensions().map((e) => e.name) + registerInstalledApps(rosterQuery.data) + return registeredApps().map((e) => e.name) }, [rosterQuery.data]) - // Accent per extension, handed out by registration order (the harness-colour + // Accent per app, handed out by registration order (the harness-colour // pattern) — no per-name CSS, and stable from first paint. - const accent = useMemo(() => extensionAccent(registered), [registered]) - // The first registered extension is the shell's default landing + fallback for the - // extension-independent pages that carry no extension of their own. - const defaultExtension = registered[0] ?? null + const accent = useMemo(() => appAccent(registered), [registered]) + // The first registered app is the shell's default landing + fallback for the + // app-independent pages that carry no app of their own. + const defaultApp = registered[0] ?? null - // Remember the last extension the operator was in. When the URL points at an - // extension-independent page (/usage, /events), the URL carries no extension + // Remember the last app the operator was in. When the URL points at an + // app-independent page (/usage, /events), the URL carries no app // signal, so we read the remembered value rather than defaulting — that way Esc - // and the BackToExtension affordance land back where the operator came from. - const [lastExtension, setLastExtension] = useState(null) - const urlExtension = extensionOwning(location) - // Adjust the remembered extension during render (React's documented pattern for + // and the BackToApp affordance land back where the operator came from. + const [lastApp, setLastApp] = useState(null) + const urlApp = appOwning(location) + // Adjust the remembered app during render (React's documented pattern for // deriving state from a changing input) instead of in an effect. - if (urlExtension !== null && urlExtension !== lastExtension) { - setLastExtension(urlExtension) + if (urlApp && urlApp !== lastApp) { + setLastApp(urlApp) } - const extension = urlExtension ?? lastExtension ?? defaultExtension - const ui = extension ? getExtensionUI(extension) : undefined + const app = urlApp ?? lastApp ?? defaultApp + const ui = app ? getAppUI(app) : undefined const [settingsOpen, setSettingsOpen] = useState(false) // System health for the persistent SystemStrip — the webhook / spend status bar an - // extension opts into via its registry entry (a tracker-less extension leaves it - // off). Polls the lean /api/system/health only while an opted-in extension shows. + // app opts into via its registry entry (a tracker-less app leaves it + // off). Polls the lean /api/system/health only while an opted-in app shows. const wantsHealth = Boolean(ui?.systemStrip) const { data: health } = useQuery({ queryKey: ['system-health'], @@ -82,33 +82,33 @@ function AppShell() { refetchInterval: wantsHealth ? 4000 : false, }) - // Count in-extension navigations so Esc can go back where the operator actually + // Count in-app navigations so Esc can go back where the operator actually // came from, falling back to a sensible destination only on a cold deeplink (no - // in-extension history to pop). Starts at -1 so the initial load isn't counted. + // in-app history to pop). Starts at -1 so the initial load isn't counted. const navCount = useRef(-1) useEffect(() => { navCount.current += 1 }, [location]) - // Root URL deeplinks to the default extension so the in-extension nav and the URL + // Root URL deeplinks to the default app so the in-app nav and the URL // bar agree. Waits for the registry so it lands on a real home, not a guess. useEffect(() => { - if ((location === '' || location === '/') && defaultExtension) { - navigate(extensionHome(defaultExtension), { replace: true }) + if ((location === '' || location === '/') && defaultApp) { + navigate(appHome(defaultApp), { replace: true }) } - }, [location, navigate, defaultExtension]) + }, [location, navigate, defaultApp]) useEffect(() => { - if (extension) document.body.dataset.extension = extension - }, [extension]) + if (app) document.body.dataset.app = app + }, [app]) - // Global keymap: ⌘K jumps to the default extension; Esc walks back up the stack. + // Global keymap: ⌘K jumps to the default app; Esc walks back up the stack. useEffect(() => { function onKey(event: KeyboardEvent) { const meta = event.metaKey || event.ctrlKey - if (meta && (event.key === 'k' || event.key === 'K') && defaultExtension) { + if (meta && (event.key === 'k' || event.key === 'K') && defaultApp) { event.preventDefault() - navigate(extensionHome(defaultExtension)) + navigate(appHome(defaultApp)) return } if (event.key === 'Escape') { @@ -125,36 +125,36 @@ function AppShell() { } if ( location.startsWith('/ship/work-items/') || - // The extension-independent detail pages (Usage panel, Events feed) are - // reached from appbar pills; Esc returns to the current extension's home + // The app-independent detail pages (Usage panel, Events feed) are + // reached from appbar pills; Esc returns to the current app's home // rather than leaving the operator stuck without a visible back affordance. location === '/usage' || location === '/events' ) { // Back where the operator came from. On a cold deeplink (nothing in the - // in-extension history to pop) fall back to the extension's home. + // in-app history to pop) fall back to the app's home. if (navCount.current > 0) { window.history.back() - } else if (extension) { - navigate(extensionHome(extension)) + } else if (app) { + navigate(appHome(app)) } } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [location, extension, navigate, defaultExtension]) + }, [location, app, navigate, defaultApp]) - // The SystemStrip rides above the opted-in extension's surfaces. ``.extension-main`` + // The SystemStrip rides above the opted-in app's surfaces. ``.app-main`` // is always a flex column so the strip stacks cleanly as a flex-shrink:0 band above // the .page-shell child emitted by . const wantsStrip = wantsHealth && Boolean(health) - const home = extension ? extensionHome(extension) : '/' - const accentColor = extension ? accent[extension] : undefined - // The subnav tabs the extension declared on its backend class, off the roster — - // the one nav channel, for bundled and installed extensions alike. - const navigation = rosterQuery.data?.find((entry) => entry.name === extension)?.navigation + const home = app ? appHome(app) : '/' + const accentColor = app ? accent[app] : undefined + // The subnav tabs the app declared on its backend class, off the roster — + // the one nav channel, for bundled and installed apps alike. + const navigation = rosterQuery.data?.find((entry) => entry.name === app)?.navigation return ( <> @@ -167,14 +167,14 @@ function AppShell() { / - navigate(extensionHome(next))} + onChange={(next) => navigate(appHome(next))} /> - +
setSettingsOpen(false)} /> -
+
{wantsStrip && health && } - {/* Shell-owned paths first, so an extension named after one of them can't + {/* Shell-owned paths first, so an app named after one of them can't shadow the platform surface. */} @@ -216,7 +216,7 @@ function AppShell() { {(params) => } {registered.flatMap((name) => - (getExtensionUI(name)?.routes ?? []).map((route) => ( + (getAppUI(name)?.routes ?? []).map((route) => ( {(params) => route.render(params as Record)} @@ -231,12 +231,12 @@ function AppShell() { ) } -// The extension's primary navigation — shared across every page of the extension, +// The app's primary navigation — shared across every page of the app, // list and detail alike (hiding it on detail pages stranded the operator). The tabs -// are the (url, name) pairs the extension declared on its backend class. The active +// are the (url, name) pairs the app declared on its backend class. The active // tab is the one whose url is the longest prefix of the location, so a detail page // lights its own section, not every ancestor. -function ExtensionSubNav({ +function AppSubNav({ location, entries, accent, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0228b3a5..e452f1c1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,16 +6,16 @@ import type { ConnectChallenge, Connection, DashboardHealth, - Extension, + App, FeedResponse, - ExtensionsSettingsResponse, + AppsSettingsResponse, Harness, Identity, Pat, SubjectResponse, SubjectSummary, UpdateHarnessRequest, - UpdateExtensionsSettingsRequest, + UpdateAppsSettingsRequest, UpdateUserSettingsRequest, UsageHistoryResponse, UsageResponse, @@ -160,30 +160,30 @@ export const identityApi = { }, } -// The generic subject read-side every extension gets for free at -// ``/api///...`` (the platform serves status + timeline; -// the extension supplies only its domain summary). Generic over the summary shape, -// so an extension keys these on its own subject type. +// The generic subject read-side every app gets for free at +// ``/api///...`` (the platform serves status + timeline; +// the app supplies only its domain summary). Generic over the summary shape, +// so an app keys these on its own subject type. export const subjectApi = { - base: (extension: string, subjectType: string, id: string | number) => - `/api/${extension}/${subjectType}/${id}`, - read: (extension: string, subjectType: string, id: string | number) => - getJSON>(subjectApi.base(extension, subjectType, id)), - boardStream: (extension: string, subjectType: string) => - `/api/${extension}/${subjectType}/stream`, - stream: (extension: string, subjectType: string, id: string | number) => - `${subjectApi.base(extension, subjectType, id)}/stream`, - transcriptBase: (extension: string, callId: string) => - `/api/${extension}/transcripts/${callId}`, - transcriptFiles: (extension: string, callId: string) => - getJSON(`/api/${extension}/transcripts/${callId}/files`), - transcriptFile: (extension: string, callId: string, name: string) => - `/api/${extension}/transcripts/${callId}/files/${encodeURIComponent(name)}`, + base: (app: string, subjectType: string, id: string | number) => + `/api/${app}/${subjectType}/${id}`, + read: (app: string, subjectType: string, id: string | number) => + getJSON>(subjectApi.base(app, subjectType, id)), + boardStream: (app: string, subjectType: string) => + `/api/${app}/${subjectType}/stream`, + stream: (app: string, subjectType: string, id: string | number) => + `${subjectApi.base(app, subjectType, id)}/stream`, + transcriptBase: (app: string, callId: string) => + `/api/${app}/transcripts/${callId}`, + transcriptFiles: (app: string, callId: string) => + getJSON(`/api/${app}/transcripts/${callId}/files`), + transcriptFile: (app: string, callId: string, name: string) => + `/api/${app}/transcripts/${callId}/files/${encodeURIComponent(name)}`, } export const api = { systemHealth: () => getJSON('/api/system/health'), - listExtensions: () => getJSON('/api/extensions'), + listApps: () => getJSON('/api/apps'), artifact: (id: string) => getJSON(`/api/artifacts/${id}`), resumeRun: ( runId: string, @@ -194,11 +194,11 @@ export const api = { postJSON<{ run: string; result: string }>(`/api/runs/${runId}/cancel`, { reason }), retryRun: (runId: string) => postJSON<{ run: string }>(`/api/runs/${runId}/retry`, undefined), - listEvents: (params: { limit?: number; before?: string; extension?: string } = {}) => { + listEvents: (params: { limit?: number; before?: string; app?: string } = {}) => { const query = new URLSearchParams() if (params.limit !== undefined) query.set('limit', String(params.limit)) if (params.before !== undefined) query.set('before', params.before) - if (params.extension !== undefined) query.set('extension', params.extension) + if (params.app !== undefined) query.set('app', params.app) const qs = query.toString() return getJSON(`/api/events${qs ? `?${qs}` : ''}`) }, @@ -243,9 +243,9 @@ export const api = { `/api/browser-sessions/${encodeURIComponent(name)}/login-window/cancel`, undefined, ), - getExtensionSettings: () => getJSON('/api/settings/extensions'), - updateExtensionSettings: (body: UpdateExtensionsSettingsRequest) => - patchJSON('/api/settings/extensions', body), + getAppSettings: () => getJSON('/api/settings/apps'), + updateAppSettings: (body: UpdateAppsSettingsRequest) => + patchJSON('/api/settings/apps', body), usage: () => getJSON('/api/usage'), refreshUsage: () => postJSON('/api/usage/refresh', {}), usageHistory: () => getJSON('/api/usage/history'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index effa41e8..e33296bd 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -15,8 +15,8 @@ // --- Platform: subjects, runs, agent calls --------------------------------- -// Served by the platform layer (durable/schemas.py) for every extension. An -// extension keys its board/detail on its own subject summary; SubjectRow and +// Served by the platform layer (durable/schemas.py) for every app. An +// app keys its board/detail on its own subject summary; SubjectRow and // SubjectResponse are generic over that summary (a WorkItemSummary for build). // The platform's canonical lifecycle states, aggregated across a subject's runs. @@ -30,7 +30,7 @@ export type RunState = // The run's DBOS workflow row is gone; it will never start. | 'orphaned' -// The base every extension's subject summary satisfies; ``id`` keys its status, +// The base every app's subject summary satisfies; ``id`` keys its status, // timeline, and detail URL. export interface SubjectSummary { id: string @@ -39,11 +39,11 @@ export interface SubjectSummary { export interface SubjectStatus { state: RunState - // Facts the extension renders its lane copy from; the backend ships no prose. + // Facts the app renders its lane copy from; the backend ships no prose. // The driving run's kind and, while running, its latest agent call's agent. kind: string | null agent: string | null - // A parked run's gate identity; the extension maps it to its own words. + // A parked run's gate identity; the app maps it to its own words. gate: string | null // The failed driving run's stop reason, and its machine classification // ("gate_timeout" = unanswered gate, not a crash). @@ -139,7 +139,7 @@ export interface SubjectRow { // A subject's full read view: domain summary, status, the platform timeline // (the subject's runs, oldest first, each with its agent calls), and the -// extension's optional live activity (the running sub-phase). +// app's optional live activity (the running sub-phase). export interface SubjectResponse { summary: S status: SubjectStatus @@ -183,11 +183,11 @@ export interface TranscriptChunk { text: string } -// One installed extension, from the backend registry — what the shell derives +// One installed app, from the backend registry — what the shell derives // nav and generic pages from. ``hasFrontend`` means the package ships its own // built UI (an ESM module the shell mounts, served under /app/); -// ``navigation`` is the subnav tabs the extension declares, as (url, name) pairs. -export interface Extension { +// ``navigation`` is the subnav tabs the app declares, as (url, name) pairs. +export interface App { name: string icon: string description: string @@ -330,7 +330,7 @@ export interface BrowserSession { * on yet has none. */ payloadFormat: BrowserSessionPayloadFormat | null site: string - /** False for a leftover row whose declaring extension is gone. */ + /** False for a leftover row whose declaring app is gone. */ isDeclared: boolean createdAt: string | null lastRefreshedAt: string | null @@ -357,7 +357,7 @@ export interface AgentSetting { timeoutSource: EffortSource } -// --- Per-extension settings (declaration-driven) -------------------------------- +// --- Per-app settings (declaration-driven) -------------------------------- export interface WorkflowSettingField { name: string @@ -388,34 +388,34 @@ export interface WorkflowSettings { fields: WorkflowSettingField[] } -export interface ExtensionSettings { +export interface AppSettings { name: string description: string - /** Lucide icon name for the rail glyph (see EXTENSION_ICONS); falls back if unknown. */ + /** Lucide icon name for the rail glyph (see APP_ICONS); falls back if unknown. */ icon: string /** Built-in (platform-core) apps render under the Druks tab, not their own. */ builtin: boolean agents: AgentSetting[] workflows: WorkflowSettings[] - /** The extension's own settings (not tied to a workflow). */ + /** The app's own settings (not tied to a workflow). */ settings: WorkflowSettingField[] } -export interface ExtensionsSettingsResponse { +export interface AppsSettingsResponse { allowedEfforts: string[] - extensions: ExtensionSettings[] + apps: AppSettings[] } -export type ExtensionSettingsProblems = Record> +export type AppSettingsProblems = Record> -export interface UpdateExtensionsSettingsRequest { +export interface UpdateAppsSettingsRequest { agentModels?: Record agentEfforts?: Record agentTimeouts?: Record /** Keyed by workflow kind. */ workflowSettings?: Record> - /** Keyed by extension name. */ - extensionSettings?: Record> + /** Keyed by app name. */ + appSettings?: Record> } // --- Activity feed --------------------------------------------------------- @@ -424,10 +424,10 @@ export interface FeedItem { id: string seq: number at: string - // A lifecycle topic ("workflow.finished") or the milestone an extension recorded + // A lifecycle topic ("workflow.finished") or the milestone an app recorded // ("merged"). The words are this client's — see lib/feed. kind: string - extension?: string | null + app?: string | null // The durable kind of the workflow a lifecycle row is about ("ship.build"). workflow?: string | null subjectType?: string | null diff --git a/frontend/src/extensions/InstalledAppHost.tsx b/frontend/src/apps/InstalledAppHost.tsx similarity index 90% rename from frontend/src/extensions/InstalledAppHost.tsx rename to frontend/src/apps/InstalledAppHost.tsx index 70c621f2..055a0816 100644 --- a/frontend/src/extensions/InstalledAppHost.tsx +++ b/frontend/src/apps/InstalledAppHost.tsx @@ -4,8 +4,8 @@ import { useLocation } from 'wouter' import { EmptyState } from '../components/EmptyState' import { Markdown } from '../components/Markdown' import { Page } from '../components/Page' -import { extensionAccent } from '../lib/extensionColors' -import { registeredExtensions } from './registry' +import { appAccent } from '../lib/appColors' +import { registeredApps } from './registry' // The shell↔app mount contract. A dist app's entry module exports // ``shellApi`` (the contract version it was built against) and @@ -22,7 +22,7 @@ export interface ShellContext { // re-broadcasts every location change as a ``popstate`` event while the app // is mounted, so an app's router is one listener + location.pathname. navigate: (path: string) => void - // The accent the shell assigned this extension. Everything else about the + // The accent the shell assigned this app. Everything else about the // theme is ambient: the app renders in the shell's document, so the shell's // CSS variables cascade into it. theme: { accent: string } @@ -37,7 +37,7 @@ interface EntryModule { mount?: unknown } -// Mounts an installed extension's shipped ESM bundle below the chrome: loads +// Mounts an installed app's shipped ESM bundle below the chrome: loads // its stylesheet, imports /app//entry.js, checks the contract, and hands // the app a container plus the ShellContext. The chrome (switcher, events, // usage) stays mounted — crossing shell↔app is a route change, not a document @@ -65,14 +65,14 @@ export function InstalledAppHost({ name }: { name: string }) { stylesheet.href = `/app/${name}/style.css` document.head.appendChild(stylesheet) - // The accent the shell assigned this extension: the same registry-order + // The accent the shell assigned this app: the same registry-order // palette the appbar reads, resolved here so the route needs no plumbing. - const names = registeredExtensions().map((ui) => ui.name) + const names = registeredApps().map((ui) => ui.name) const ctx: ShellContext = { shellApi: SHELL_API, apiBase: `/api/${name}`, navigate: (path) => navigateRef.current(path), - theme: { accent: extensionAccent(names)[name] ?? '' }, + theme: { accent: appAccent(names)[name] ?? '' }, markdown: (source) => , } diff --git a/frontend/src/apps/index.ts b/frontend/src/apps/index.ts new file mode 100644 index 00000000..a80fcbc2 --- /dev/null +++ b/frontend/src/apps/index.ts @@ -0,0 +1,6 @@ +// Import every bundled app's UI module for its registration side effect. An +// app contributes frontend by calling ``registerAppUI`` at import time; +// listing it here is what pulls it into the bundle. Adding an app's UI is one +// line here — the shell (App, AppDropdown, api/client) never learns its name. +import './ship/ui' +import './review/ui' diff --git a/frontend/src/extensions/installed.tsx b/frontend/src/apps/installed.tsx similarity index 72% rename from frontend/src/extensions/installed.tsx rename to frontend/src/apps/installed.tsx index 169eba25..ba19dd37 100644 --- a/frontend/src/extensions/installed.tsx +++ b/frontend/src/apps/installed.tsx @@ -1,23 +1,23 @@ -import type { Extension } from '../api/types' -import { ExtensionHomePage } from '../pages/ExtensionHomePage' +import type { App } from '../api/types' +import { AppHomePage } from '../pages/AppHomePage' import { SubjectPage } from '../pages/SubjectPage' import { InstalledAppHost } from './InstalledAppHost' -import { getExtensionUI, registerExtensionUI } from './registry' +import { getAppUI, registerAppUI } from './registry' -// Registers the backend roster into the same UI registry the bundled extensions -// use: an installed extension without bundled UI gets the generic pages; one that +// Registers the backend roster into the same UI registry the bundled apps +// use: an installed app without bundled UI gets the generic pages; one that // ships its own dist gets mounted inside the shell at /. Bundled // registration wins — a name already present is left alone. Idempotent, called // on every roster response. -export function registerInstalledExtensions(roster: Extension[] | undefined): void { +export function registerInstalledApps(roster: App[] | undefined): void { const apps = (roster ?? []).filter((info) => !info.builtin) apps.sort((a, b) => a.name.localeCompare(b.name)) for (const info of apps) { - if (!getExtensionUI(info.name)) registerExtensionUI(installedUI(info)) + if (!getAppUI(info.name)) registerAppUI(installedUI(info)) } } -function installedUI(info: Extension) { +function installedUI(info: App) { const name = info.name if (info.hasFrontend) { return { @@ -37,8 +37,8 @@ function installedUI(info: Extension) { { path: `/${name}`, render: () => ( - @@ -49,7 +49,7 @@ function installedUI(info: Extension) { path: `/${name}/:subjectType/*`, render: (params: Record) => ( diff --git a/frontend/src/apps/registry.tsx b/frontend/src/apps/registry.tsx new file mode 100644 index 00000000..565827bb --- /dev/null +++ b/frontend/src/apps/registry.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from 'react' + +// The client-side app-UI registry: how an app contributes frontend. +// An app calls ``registerAppUI`` once at import time with the routes +// its pages live at; the shell mounts them. Subnav tabs are not part of this — +// every app declares those on its backend class, and the shell renders them +// from the roster. An app that registers nothing still gets feed, settings, +// and usage from the shell for free — those are platform surfaces, not +// per-app contributions. + +// One route an app mounts. ``path`` is a wouter pattern under the router base +// (e.g. ``/ship`` or ``/ship/work-items/:slug``); ``render`` receives the matched +// params. +export interface AppRoute { + path: string + render: (params: Record) => ReactNode +} + +export interface AppUI { + // The app's name — the same identifier the backend registry keys it by. + name: string + // The path the brand + dropdown land on (defaults to ``/``). + home?: string + routes: AppRoute[] + // Where a feed row about one of this app's subjects navigates. The shell knows + // an app has subjects, never where its pages put them. + subjectPath?: (subject: { type: string; id: string }) => string | undefined + // Whether the persistent system-health strip (webhook + spend) rides above this + // app's list and detail surfaces. Opt-in — an app that doesn't track + // code hosts leaves it off and the band never renders. + systemStrip?: boolean +} + +// The switcher/display label for an app — derived from its name, never +// declared: underscores become spaces, the lowercase house style stays. +export function appLabel(name: string): string { + return name.replace(/_/g, ' ') +} + +const REGISTRY = new Map() + +export function registerAppUI(ui: AppUI): void { + REGISTRY.set(ui.name, ui) +} + +export function getAppUI(name: string): AppUI | undefined { + return REGISTRY.get(name) +} + +// Every UI-contributing app, in registration order. Available synchronously at +// import, so the shell mounts routes from this — direct URLs work on a cold load, not +// only once the async settings response arrives. +export function registeredApps(): AppUI[] { + return [...REGISTRY.values()] +} + +// The home path the shell navigates to for an app — its declared ``home`` or +// the conventional ``/``. Apps with no UI contribution still resolve to a +// home so the dropdown and Esc have somewhere to land. +export function appHome(name: string): string { + return REGISTRY.get(name)?.home ?? `/${name}` +} + +// A wouter-style pattern (``/ship/work-items/:slug``) as a regex anchored to the +// whole path — for deciding which app owns the current URL (its dropdown + +// accent). +function patternRegex(pattern: string): RegExp { + const source = pattern.replace(/:[^/]+/g, '[^/]+') + return new RegExp(`^${source}$`) +} + +// The registered app that owns a location: the one whose home the path sits +// under, or whose routes match it. Reads the local registry (synchronous), so a direct +// load resolves the app for its dropdown + accent without waiting on the settings +// fetch. Null on shell-owned paths (/usage, /events, /). +export function appOwning(location: string): string | null { + for (const ui of REGISTRY.values()) { + const home = ui.home ?? `/${ui.name}` + if (location === home || location.startsWith(`${home}/`)) return ui.name + if (ui.routes.some((route) => patternRegex(route.path).test(location))) return ui.name + } + return null +} diff --git a/frontend/src/extensions/review/ReviewsPage.tsx b/frontend/src/apps/review/ReviewsPage.tsx similarity index 98% rename from frontend/src/extensions/review/ReviewsPage.tsx rename to frontend/src/apps/review/ReviewsPage.tsx index c904ec2f..61a276b5 100644 --- a/frontend/src/extensions/review/ReviewsPage.tsx +++ b/frontend/src/apps/review/ReviewsPage.tsx @@ -19,7 +19,7 @@ export function ReviewsPage() { // the feed; each means the open set changed — re-fetch it. RelTime already // ticks the clock between events, so the feed only has to carry state changes. const queryClient = useQueryClient() - useSSE(`/api/events/stream?extension=${REVIEW}`, { + useSSE(`/api/events/stream?app=${REVIEW}`, { handlers: useMemo( () => ({ message: () => void queryClient.invalidateQueries({ queryKey: ['reviews'] }) }), [queryClient], diff --git a/frontend/src/extensions/review/api.ts b/frontend/src/apps/review/api.ts similarity index 100% rename from frontend/src/extensions/review/api.ts rename to frontend/src/apps/review/api.ts diff --git a/frontend/src/extensions/review/reviewLine.ts b/frontend/src/apps/review/reviewLine.ts similarity index 91% rename from frontend/src/extensions/review/reviewLine.ts rename to frontend/src/apps/review/reviewLine.ts index 7111a617..cb8c5ed8 100644 --- a/frontend/src/extensions/review/reviewLine.ts +++ b/frontend/src/apps/review/reviewLine.ts @@ -1,7 +1,7 @@ import type { SubjectStatus } from '../../api/types' // Review's copy, composed from the facts the backend ships — it sends data, the -// extension owns its own vocabulary. +// app owns its own vocabulary. export function reviewLine(status: SubjectStatus): string { if (status.state === 'running' || status.state === 'scheduled') return 'Reviewing' if (status.state === 'failed') return status.failure ?? 'Review failed' diff --git a/frontend/src/extensions/review/ui.tsx b/frontend/src/apps/review/ui.tsx similarity index 71% rename from frontend/src/extensions/review/ui.tsx rename to frontend/src/apps/review/ui.tsx index 0f84da2e..fff2fa69 100644 --- a/frontend/src/extensions/review/ui.tsx +++ b/frontend/src/apps/review/ui.tsx @@ -1,8 +1,8 @@ -import { registerExtensionUI } from '../registry' +import { registerAppUI } from '../registry' import { REVIEW } from './api' import { ReviewsPage } from './ReviewsPage' -registerExtensionUI({ +registerAppUI({ name: REVIEW, home: `/${REVIEW}`, routes: [{ path: `/${REVIEW}`, render: () => }], diff --git a/frontend/src/extensions/ship/AgentCallPage.tsx b/frontend/src/apps/ship/AgentCallPage.tsx similarity index 99% rename from frontend/src/extensions/ship/AgentCallPage.tsx rename to frontend/src/apps/ship/AgentCallPage.tsx index 4542f9e4..8e0a378a 100644 --- a/frontend/src/extensions/ship/AgentCallPage.tsx +++ b/frontend/src/apps/ship/AgentCallPage.tsx @@ -21,7 +21,7 @@ interface Props { export function AgentCallPage({ workItemId, runId }: Props) { // The call lives on its subject's timeline; its files come from the platform's - // per-call read-side. Both are extension-agnostic platform routes. + // per-call read-side. Both are app-agnostic platform routes. const query = useQuery({ queryKey: ['agent-call', workItemId, runId], queryFn: async () => { diff --git a/frontend/src/extensions/ship/HistoryPage.tsx b/frontend/src/apps/ship/HistoryPage.tsx similarity index 100% rename from frontend/src/extensions/ship/HistoryPage.tsx rename to frontend/src/apps/ship/HistoryPage.tsx diff --git a/frontend/src/extensions/ship/NotFound.tsx b/frontend/src/apps/ship/NotFound.tsx similarity index 100% rename from frontend/src/extensions/ship/NotFound.tsx rename to frontend/src/apps/ship/NotFound.tsx diff --git a/frontend/src/extensions/ship/StatusTag.tsx b/frontend/src/apps/ship/StatusTag.tsx similarity index 100% rename from frontend/src/extensions/ship/StatusTag.tsx rename to frontend/src/apps/ship/StatusTag.tsx diff --git a/frontend/src/extensions/ship/WorkItemPage.tsx b/frontend/src/apps/ship/WorkItemPage.tsx similarity index 99% rename from frontend/src/extensions/ship/WorkItemPage.tsx rename to frontend/src/apps/ship/WorkItemPage.tsx index 4495922a..ab1c7633 100644 --- a/frontend/src/extensions/ship/WorkItemPage.tsx +++ b/frontend/src/apps/ship/WorkItemPage.tsx @@ -606,7 +606,7 @@ function TranscriptBody({ const isLive = call?.status === 'running' // Running but no agent call yet → the sandbox spin-up window. The live phase - // (the extension's activity: "Building sandbox VM…", "Working…") names what's + // (the app's activity: "Building sandbox VM…", "Working…") names what's // happening in that window; falls back to a generic phrase before it's pushed. if (call == null) { if (isRunning(run)) { diff --git a/frontend/src/extensions/ship/WorkItemsPage.tsx b/frontend/src/apps/ship/WorkItemsPage.tsx similarity index 100% rename from frontend/src/extensions/ship/WorkItemsPage.tsx rename to frontend/src/apps/ship/WorkItemsPage.tsx diff --git a/frontend/src/extensions/ship/api.ts b/frontend/src/apps/ship/api.ts similarity index 97% rename from frontend/src/extensions/ship/api.ts rename to frontend/src/apps/ship/api.ts index 5b1ebf59..6255bd02 100644 --- a/frontend/src/extensions/ship/api.ts +++ b/frontend/src/apps/ship/api.ts @@ -10,7 +10,7 @@ import type { SubjectResponse, SubjectRow, SubjectSummary } from '../../api/type // Ship's identity on the platform: the name that keys its ``/api/ship`` namespace // and the subject type its runs are about. The only place these literals live — the -// generic shell reads the extension name off the registry, never hardcodes it. +// generic shell reads the app name off the registry, never hardcodes it. export const SHIP = 'ship' export const WORK_ITEM = 'work_item' export const PROJECT_REPO = 'project_repo' diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx b/frontend/src/apps/ship/projects/ProjectsPage.test.tsx similarity index 100% rename from frontend/src/extensions/ship/projects/ProjectsPage.test.tsx rename to frontend/src/apps/ship/projects/ProjectsPage.test.tsx diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.tsx b/frontend/src/apps/ship/projects/ProjectsPage.tsx similarity index 99% rename from frontend/src/extensions/ship/projects/ProjectsPage.tsx rename to frontend/src/apps/ship/projects/ProjectsPage.tsx index ebc0dc36..b87d1126 100644 --- a/frontend/src/extensions/ship/projects/ProjectsPage.tsx +++ b/frontend/src/apps/ship/projects/ProjectsPage.tsx @@ -79,7 +79,7 @@ export function ProjectsPage() { = { diff --git a/frontend/src/extensions/ship/ui.tsx b/frontend/src/apps/ship/ui.tsx similarity index 81% rename from frontend/src/extensions/ship/ui.tsx rename to frontend/src/apps/ship/ui.tsx index bb22d125..12952af2 100644 --- a/frontend/src/extensions/ship/ui.tsx +++ b/frontend/src/apps/ship/ui.tsx @@ -1,4 +1,4 @@ -import { registerExtensionUI } from '../registry' +import { registerAppUI } from '../registry' import { SHIP } from './api' import { parseLeadingId } from './slug' import { AgentCallPage } from './AgentCallPage' @@ -8,10 +8,10 @@ import { ProjectsPage } from './projects/ProjectsPage' import { WorkItemPage } from './WorkItemPage' import { WorkItemsPage } from './WorkItemsPage' -// Ship contributes its UI through the same registry any extension uses — its pages -// are not the app's spine, they're one extension's routes. The subnav tabs come from -// the extension's backend declaration; feed, settings, and usage it gets for free. -registerExtensionUI({ +// Ship contributes its UI through the same registry any app uses — its pages +// are not the app's spine, they're one app's routes. The subnav tabs come from +// the app's backend declaration; feed, settings, and usage it gets for free. +registerAppUI({ name: SHIP, home: `/${SHIP}`, systemStrip: true, diff --git a/frontend/src/components/ExtensionDropdown.tsx b/frontend/src/components/AppDropdown.tsx similarity index 58% rename from frontend/src/components/ExtensionDropdown.tsx rename to frontend/src/components/AppDropdown.tsx index 4cbaccc6..5ba95d2e 100644 --- a/frontend/src/components/ExtensionDropdown.tsx +++ b/frontend/src/components/AppDropdown.tsx @@ -2,38 +2,38 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useQuery } from '@tanstack/react-query' import { api } from '../api/client' -import { extensionLabel } from '../extensions/registry' -import { ExtensionGlyph } from './ExtensionGlyph' +import { appLabel } from '../apps/registry' +import { AppGlyph } from './AppGlyph' interface Props { - // The extensions that contribute UI, in registry order — the dropdown's options. - extensions: string[] - // The extension currently in view (null before the registry loads). - extension: string | null - // Accent per extension name (registry-order palette) for the trigger + active item. + // The apps that contribute UI, in registry order — the dropdown's options. + apps: string[] + // The app currently in view (null before the registry loads). + app: string | null + // Accent per app name (registry-order palette) for the trigger + active item. accent: Record - onChange: (extension: string) => void + onChange: (app: string) => void } -export function ExtensionDropdown({ extensions, extension, accent, onChange }: Props) { +export function AppDropdown({ apps, app, accent, onChange }: Props) { const [open, setOpen] = useState(false) const ref = useRef(null) // Icon + description come from the roster the shell already fetched (same query // key, so this reads the cache — no extra request). const rosterQuery = useQuery({ - queryKey: ['extensions'], - queryFn: api.listExtensions, + queryKey: ['apps'], + queryFn: api.listApps, staleTime: 60_000, }) const meta = useMemo(() => { const byName = new Map((rosterQuery.data ?? []).map((e) => [e.name, e])) - return extensions.map((name) => ({ + return apps.map((name) => ({ name, icon: byName.get(name)?.icon ?? 'box', desc: byName.get(name)?.description ?? '', })) - }, [rosterQuery.data, extensions]) + }, [rosterQuery.data, apps]) useEffect(() => { if (!open) return undefined @@ -51,30 +51,30 @@ export function ExtensionDropdown({ extensions, extension, accent, onChange }: P } }, [open]) - const current = meta.find((m) => m.name === extension) ?? meta[0] + const current = meta.find((m) => m.name === app) ?? meta[0] if (!current) return null const currentAccent = accent[current.name] return ( -
+
{open && ( -
+
{meta.map((m) => { - const selected = m.name === extension + const selected = m.name === app const itemAccent = accent[m.name] return ( ) diff --git a/frontend/src/components/ExtensionGlyph.tsx b/frontend/src/components/AppGlyph.tsx similarity index 79% rename from frontend/src/components/ExtensionGlyph.tsx rename to frontend/src/components/AppGlyph.tsx index 148d3166..2eed281d 100644 --- a/frontend/src/components/ExtensionGlyph.tsx +++ b/frontend/src/components/AppGlyph.tsx @@ -6,10 +6,10 @@ import { Terminal, Wand, Workflow, Wrench, Zap, type LucideIcon, } from 'lucide-react' -// The Lucide icons an extension can name (the Obsidian model — an extension sets +// The Lucide icons an app can name (the Obsidian model — an app sets // ``icon = "telescope"`` and it resolves here, no frontend edit for a packaged -// extension). Add an icon with one import + one entry. Unknown names fall back to box. -const EXTENSION_ICONS: Record = { +// app). Add an icon with one import + one entry. Unknown names fall back to box. +const APP_ICONS: Record = { box: Box, hammer: Hammer, hexagon: Hexagon, telescope: Telescope, search: Search, radar: Radar, compass: Compass, eye: Eye, bot: Bot, sparkles: Sparkles, zap: Zap, microscope: Microscope, rocket: Rocket, package: Package, 'git-branch': GitBranch, @@ -22,7 +22,7 @@ const EXTENSION_ICONS: Record = { workflow: Workflow, } -export function ExtensionGlyph({ name, size = 15 }: { name: string; size?: number }) { - const Icon = EXTENSION_ICONS[name] ?? Box +export function AppGlyph({ name, size = 15 }: { name: string; size?: number }) { + const Icon = APP_ICONS[name] ?? Box return } diff --git a/frontend/src/components/BackToApp.tsx b/frontend/src/components/BackToApp.tsx new file mode 100644 index 00000000..e47075e5 --- /dev/null +++ b/frontend/src/components/BackToApp.tsx @@ -0,0 +1,33 @@ +import { Link } from 'wouter' + +import { appHome } from '../apps/registry' + +/** + * Inline back link for app-independent detail pages (/usage, /events). + * + * Those pages are reached from appbar pills and otherwise have no + * obvious navigation back to an app dashboard — operators pressed + * Esc, got nothing, hunted for a close button, gave up. The Esc + * handler in ``AppShell`` now routes back via the global keymap; + * this component is the visible counterpart for operators who don't + * know the shortcut. + * + * The app is read from ``document.body.dataset.app``, which + * ``AppShell`` sets on every render, then resolved to its declared home + * through the registry — so an app with a custom ``home`` lands on + * it, not a guessed ``/``. The URL of an app-independent page + * (``/usage``, ``/events``) carries no app signal of its own. + */ +export function BackToApp() { + const app = document.body.dataset.app + if (!app) return null + return ( + + ← back to {app} + + ) +} diff --git a/frontend/src/components/BackToExtension.tsx b/frontend/src/components/BackToExtension.tsx deleted file mode 100644 index e5bc4c24..00000000 --- a/frontend/src/components/BackToExtension.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Link } from 'wouter' - -import { extensionHome } from '../extensions/registry' - -/** - * Inline back link for extension-independent detail pages (/usage, /events). - * - * Those pages are reached from appbar pills and otherwise have no - * obvious navigation back to a extension dashboard — operators pressed - * Esc, got nothing, hunted for a close button, gave up. The Esc - * handler in ``AppShell`` now routes back via the global keymap; - * this component is the visible counterpart for operators who don't - * know the shortcut. - * - * The extension is read from ``document.body.dataset.extension``, which - * ``AppShell`` sets on every render, then resolved to its declared home - * through the registry — so an extension with a custom ``home`` lands on - * it, not a guessed ``/``. The URL of a extension-independent page - * (``/usage``, ``/events``) carries no extension signal of its own. - */ -export function BackToExtension() { - const extension = document.body.dataset.extension - if (!extension) return null - return ( - - ← back to {extension} - - ) -} diff --git a/frontend/src/components/BrowserSessionsPane.tsx b/frontend/src/components/BrowserSessionsPane.tsx index 91047e07..9d1d7774 100644 --- a/frontend/src/components/BrowserSessionsPane.tsx +++ b/frontend/src/components/BrowserSessionsPane.tsx @@ -58,7 +58,7 @@ export function BrowserSessionsPane() {

Browser

- Sign-ins your extensions declare, kept as encrypted browser state. + Sign-ins your apps declare, kept as encrypted browser state.

@@ -73,7 +73,7 @@ export function BrowserSessionsPane() { Sessions {sessions.length} {!query.isLoading && sessions.length === 0 && ( -

No installed extension declares a browser session.

+

No installed app declares a browser session.

)} {sessions.length > 0 && (
diff --git a/frontend/src/components/RunTranscript.tsx b/frontend/src/components/RunTranscript.tsx index 552c5f88..03160312 100644 --- a/frontend/src/components/RunTranscript.tsx +++ b/frontend/src/components/RunTranscript.tsx @@ -7,7 +7,7 @@ const TRANSCRIPT_CHUNK_LIMIT = 256 * 1024 interface RunTranscriptProps { // Full URL of an agent call's transcript resource, e.g. - // ``/api//transcripts/``. It serves the paginated chunk + // ``/api//transcripts/``. It serves the paginated chunk // directly and the live SSE at ``/stream``. Live tailing works the same way: the // SSE generator tails the on-disk log the worker tees to. basePath: string diff --git a/frontend/src/components/SettingsModal.test.tsx b/frontend/src/components/SettingsModal.test.tsx index 8052f069..a0bacdb8 100644 --- a/frontend/src/components/SettingsModal.test.tsx +++ b/frontend/src/components/SettingsModal.test.tsx @@ -4,9 +4,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { SettingsModal } from './SettingsModal' -const extensionSettings = { +const appSettings = { allowedEfforts: [], - extensions: [ + apps: [ { name: 'ship', description: 'Ship settings', @@ -139,15 +139,15 @@ function stubFetch( 'fetch', vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const path = String(input) - if (path === '/api/settings/extensions' && init?.method === 'PATCH') { + if (path === '/api/settings/apps' && init?.method === 'PATCH') { if (!shouldRejectPatch) return new Response('{}', { status: 200 }) return new Response(JSON.stringify({ detail }), { status: 422, statusText: 'Unprocessable Entity', }) } - if (path === '/api/settings/extensions') { - return new Response(JSON.stringify(extensionSettings), { status: 200 }) + if (path === '/api/settings/apps') { + return new Response(JSON.stringify(appSettings), { status: 200 }) } if (path === '/api/settings/harnesses') { return new Response('[]', { status: 200 }) @@ -180,7 +180,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('SettingsModal extension fields', () => { +describe('SettingsModal app fields', () => { it('opens the browser sessions pane from the settings rail', async () => { stubFetch() renderModal() @@ -189,7 +189,7 @@ describe('SettingsModal extension fields', () => { expect(await screen.findByRole('heading', { name: 'Browser' })).toBeTruthy() expect( - await screen.findByText('No installed extension declares a browser session.'), + await screen.findByText('No installed app declares a browser session.'), ).toBeTruthy() }) @@ -247,10 +247,10 @@ describe('SettingsModal extension fields', () => { .mocked(fetch) .mock.calls.find( ([input, init]) => - String(input) === '/api/settings/extensions' && init?.method === 'PATCH', + String(input) === '/api/settings/apps' && init?.method === 'PATCH', ) const body = JSON.parse(String(patchCall?.[1]?.body)) - expect(body.extensionSettings.ship).toEqual({ tracker: 'jira' }) + expect(body.appSettings.ship).toEqual({ tracker: 'jira' }) }) it('renders a multiline secret as a textarea and PATCHes the paste with newlines intact', async () => { @@ -279,10 +279,10 @@ describe('SettingsModal extension fields', () => { .mocked(fetch) .mock.calls.find( ([input, init]) => - String(input) === '/api/settings/extensions' && init?.method === 'PATCH', + String(input) === '/api/settings/apps' && init?.method === 'PATCH', ) const body = JSON.parse(String(patchCall?.[1]?.body)) - expect(body.extensionSettings.review).toEqual({ private_key: pem }) + expect(body.appSettings.review).toEqual({ private_key: pem }) }) it('renders a 422 message for a field hidden by the tracker selection', async () => { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 63516d53..52f66e6d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -5,12 +5,12 @@ import { ApiError, api } from '../api/client' import { BrowserSessionsPane } from './BrowserSessionsPane' import { TextInput } from './Control' import { SettingField } from './SettingField' -import { ExtensionGlyph } from './ExtensionGlyph' +import { AppGlyph } from './AppGlyph' import { ConnectSteps, useHarnessConnect } from './HarnessConnectFlow' import { type Harness, - type ExtensionSettings, - type ExtensionSettingsProblems, + type AppSettings, + type AppSettingsProblems, type McpRegistryCandidate, type McpServer, type Pat, @@ -18,11 +18,11 @@ import { type Service, type SkillCollection, type UpdateHarnessRequest, - type UpdateExtensionsSettingsRequest, + type UpdateAppsSettingsRequest, type UpdateUserSettingsRequest, type WorkflowSettingField, } from '../api/types' -import { extensionLabel } from '../extensions/registry' +import { appLabel } from '../apps/registry' import { absTime, relTimeFromIso } from '../lib/format' import { harnessColors } from '../lib/harnessColors' @@ -56,13 +56,13 @@ function _withField( return next } -function _areExtensionEditsDirty(edits: UpdateExtensionsSettingsRequest): boolean { +function _areAppEditsDirty(edits: UpdateAppsSettingsRequest): boolean { if (Object.keys(edits.agentModels ?? {}).length > 0) return true if (Object.keys(edits.agentEfforts ?? {}).length > 0) return true if (Object.keys(edits.agentTimeouts ?? {}).length > 0) return true if (Object.values(edits.workflowSettings ?? {}).some((fields) => Object.keys(fields).length > 0)) return true - return Object.values(edits.extensionSettings ?? {}).some( + return Object.values(edits.appSettings ?? {}).some( (fields) => Object.keys(fields).length > 0, ) } @@ -111,9 +111,9 @@ export function SettingsModal({ open, onClose }: Props) { enabled: open, staleTime: 60_000, }) - const extensionSettingsQuery = useQuery({ - queryKey: ['extensionSettings'], - queryFn: () => api.getExtensionSettings(), + const appSettingsQuery = useQuery({ + queryKey: ['appSettings'], + queryFn: () => api.getAppSettings(), enabled: open, staleTime: 60_000, }) @@ -126,26 +126,26 @@ export function SettingsModal({ open, onClose }: Props) { staleTime: 60_000, }) const [timezone, setTimezone] = useState('UTC') - // Pending per-extension setting overrides — a sparse UpdateExtensionsSettingsRequest the - // extension tabs (and the Druks tab's built-in agents) edit and submit() flushes. - // Distinct from ``knobs`` (the column-backed settings) because extension settings + // Pending per-app setting overrides — a sparse UpdateAppsSettingsRequest the + // app tabs (and the Druks tab's built-in agents) edit and submit() flushes. + // Distinct from ``knobs`` (the column-backed settings) because app settings // hit a different endpoint. - const [extensionEdits, setExtensionEdits] = useState({}) + const [appEdits, setAppEdits] = useState({}) // Pending per-harness edits (name -> sparse UpdateHarnessRequest), flushed by - // submit() — same dirty/save flow as the extension and general settings. + // submit() — same dirty/save flow as the app and general settings. const [harnessEdits, setHarnessEdits] = useState>({}) // 'general' | 'harnesses' | 'browser-sessions' | 'skills' | 'mcp' | - // 'agent-access' | + // 'agent-access' | const [section, setSection] = useState('general') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - const [extensionProblems, setExtensionProblems] = useState({}) + const [appProblems, setAppProblems] = useState({}) const [tick, setTick] = useState(0) const initialised = useRef(false) // Seed the form from the saved value the first time the modal opens // with data. Subsequent re-opens keep whatever the operator last picked - // unless they cancel — matches the rest of the extension's modal feel. + // unless they cancel — matches the rest of the app's modal feel. useEffect(() => { if (!open) { initialised.current = false @@ -153,8 +153,8 @@ export function SettingsModal({ open, onClose }: Props) { } if (!initialised.current && settingsQuery.data) { setTimezone(settingsQuery.data.timezone) - setExtensionEdits({}) - setExtensionProblems({}) + setAppEdits({}) + setAppProblems({}) setHarnessEdits({}) initialised.current = true } @@ -181,7 +181,7 @@ export function SettingsModal({ open, onClose }: Props) { window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, busy, timezone, extensionEdits, harnessEdits, onClose]) + }, [open, busy, timezone, appEdits, harnessEdits, onClose]) const timezones = useMemo(() => _listTimezones(), []) const preview = useMemo(() => { @@ -194,22 +194,22 @@ export function SettingsModal({ open, onClose }: Props) { async function submit() { setBusy(true) setError(null) - setExtensionProblems({}) - const workflows = allExtensions.flatMap((extension) => extension.workflows) - const submittedExtensionEdits: UpdateExtensionsSettingsRequest = { - ...extensionEdits, - extensionSettings: Object.fromEntries( - Object.entries(extensionEdits.extensionSettings ?? {}).map(([extensionName, changes]) => { - const fields = allExtensions.find(({ name }) => name === extensionName)?.settings ?? [] + setAppProblems({}) + const workflows = allApps.flatMap((app) => app.workflows) + const submittedAppEdits: UpdateAppsSettingsRequest = { + ...appEdits, + appSettings: Object.fromEntries( + Object.entries(appEdits.appSettings ?? {}).map(([appName, changes]) => { + const fields = allApps.find(({ name }) => name === appName)?.settings ?? [] const visibleChanges = Object.entries(changes).filter(([fieldName]) => { const field = fields.find(({ name }) => name === fieldName) return field ? isFieldVisible(field, fields, changes) : true }) - return [extensionName, Object.fromEntries(visibleChanges)] + return [appName, Object.fromEntries(visibleChanges)] }), ), workflowSettings: Object.fromEntries( - Object.entries(extensionEdits.workflowSettings ?? {}).map(([kind, changes]) => { + Object.entries(appEdits.workflowSettings ?? {}).map(([kind, changes]) => { const fields = workflows.find((workflow) => workflow.kind === kind)?.fields ?? [] const visibleChanges = Object.entries(changes).filter(([fieldName]) => { const field = fields.find(({ name }) => name === fieldName) @@ -228,9 +228,9 @@ export function SettingsModal({ open, onClose }: Props) { await api.updateSettings(body) await queryClient.invalidateQueries({ queryKey: ['settings'] }) } - if (_areExtensionEditsDirty(submittedExtensionEdits)) { - await api.updateExtensionSettings(submittedExtensionEdits) - await queryClient.invalidateQueries({ queryKey: ['extensionSettings'] }) + if (_areAppEditsDirty(submittedAppEdits)) { + await api.updateAppSettings(submittedAppEdits) + await queryClient.invalidateQueries({ queryKey: ['appSettings'] }) } const harnessChanges = Object.entries(harnessEdits).filter(([, patch]) => Object.keys(patch).length > 0) if (harnessChanges.length > 0) { @@ -246,7 +246,7 @@ export function SettingsModal({ open, onClose }: Props) { typeof caught.detail === 'object' && !Array.isArray(caught.detail) ) { - setExtensionProblems(caught.detail as ExtensionSettingsProblems) + setAppProblems(caught.detail as AppSettingsProblems) } else { setError((caught as Error).message) } @@ -257,53 +257,53 @@ export function SettingsModal({ open, onClose }: Props) { const savedTz = settingsQuery.data?.timezone const tzDirty = savedTz !== undefined && savedTz !== timezone - const extensionsDirty = _areExtensionEditsDirty(extensionEdits) + const appsDirty = _areAppEditsDirty(appEdits) const harnessesDirty = Object.values(harnessEdits).some((patch) => Object.keys(patch).length > 0) - const dirty = tzDirty || extensionsDirty || harnessesDirty + const dirty = tzDirty || appsDirty || harnessesDirty - const data = extensionSettingsQuery.data - const allExtensions = data?.extensions ?? [] + const data = appSettingsQuery.data + const allApps = data?.apps ?? [] const allowedEfforts = data?.allowedEfforts ?? [] const harnesses = harnessesQuery.data ?? [] const harnessByName: Record = Object.fromEntries( harnesses.map((h) => [h.name, h]), ) const harnessColor = harnessColors(harnesses.map((h) => h.name)) - const extensionSection = allExtensions.find((extension) => extension.name === section) + const appSection = allApps.find((app) => app.name === section) function setAgentModel(name: string, model: string | null) { - setExtensionEdits((prev) => ({ + setAppEdits((prev) => ({ ...prev, agentModels: { ...prev.agentModels, [name]: model }, })) } function setAgentEffort(name: string, effort: string | null) { - setExtensionEdits((prev) => ({ + setAppEdits((prev) => ({ ...prev, agentEfforts: { ...prev.agentEfforts, [name]: effort }, })) } function setAgentTimeout(name: string, timeout: number | null) { - setExtensionEdits((prev) => ({ + setAppEdits((prev) => ({ ...prev, agentTimeouts: { ...prev.agentTimeouts, [name]: timeout }, })) } - function setExtensionSetting(extension: string, field: string, value: unknown) { - setExtensionEdits((prev) => ({ + function setAppSetting(app: string, field: string, value: unknown) { + setAppEdits((prev) => ({ ...prev, - extensionSettings: { - ...prev.extensionSettings, - [extension]: _withField(prev.extensionSettings?.[extension], field, value), + appSettings: { + ...prev.appSettings, + [app]: _withField(prev.appSettings?.[app], field, value), }, })) - setExtensionProblems((prev) => { - const remaining = { ...prev[extension] } + setAppProblems((prev) => { + const remaining = { ...prev[app] } delete remaining[field] - return { ...prev, [extension]: remaining } + return { ...prev, [app]: remaining } }) } @@ -312,7 +312,7 @@ export function SettingsModal({ open, onClose }: Props) { } function setWorkflowField(kind: string, field: string, value: unknown) { - setExtensionEdits((prev) => ({ + setAppEdits((prev) => ({ ...prev, workflowSettings: { ...prev.workflowSettings, @@ -357,16 +357,16 @@ export function SettingsModal({ open, onClose }: Props) { setSection('mcp')} /> setSection('agent-access')} />
apps
- {allExtensions.map((extension) => ( + {allApps.map((app) => ( ))} @@ -385,7 +385,7 @@ export function SettingsModal({ open, onClose }: Props) { (harnesses.length > 0 ? ( } {section === 'mcp' && } {section === 'agent-access' && } - {extensionSection && ( - )} @@ -511,7 +511,7 @@ function RailGlyph({ name }: { name: string }) { ), - extension: ( + app: ( <> @@ -520,7 +520,7 @@ function RailGlyph({ name }: { name: string }) { } return ( - {paths[name] ?? paths.extension} + {paths[name] ?? paths.app} ) } @@ -766,7 +766,7 @@ function HarnessesPane({ busy, }: { harnesses: Harness[] - apps: ExtensionSettings[] + apps: AppSettings[] allowedEfforts: string[] edits: Record onField: (name: string, patch: UpdateHarnessRequest) => void @@ -779,7 +779,7 @@ function HarnessesPane({ // agent's model is overridden across harnesses. const agentCount = (name: string) => apps.reduce( - (count, extension) => count + extension.agents.filter((a) => harnessOfModel(a.model, harnesses) === name).length, + (count, app) => count + app.agents.filter((a) => harnessOfModel(a.model, harnesses) === name).length, 0, ) @@ -849,7 +849,7 @@ function HarnessesPane({
onField(harness.name, { fastMode: !h.fastMode })} disabled={busy} - label={`Fast extension (${harness.name})`} + label={`Fast app (${harness.name})`} />
@@ -2262,11 +2262,11 @@ function PatRow({ } // --------------------------------------------------------------------------- -// Extension pane — blurb, workflow toggles, agent table +// App pane — blurb, workflow toggles, agent table // --------------------------------------------------------------------------- -function ExtensionPane({ - extension, +function AppPane({ + app, edits, fieldErrors, harnessByName, @@ -2276,11 +2276,11 @@ function ExtensionPane({ onAgentEffort, onAgentTimeout, onWorkflowField, - onExtensionSetting, + onAppSetting, busy, }: { - extension: ExtensionSettings - edits: UpdateExtensionsSettingsRequest + app: AppSettings + edits: UpdateAppsSettingsRequest fieldErrors: Record harnessByName: Record harnessColor: Record @@ -2289,20 +2289,20 @@ function ExtensionPane({ onAgentEffort: (name: string, effort: string | null) => void onAgentTimeout: (name: string, timeout: number | null) => void onWorkflowField: (kind: string, field: string, value: unknown) => void - onExtensionSetting: (extension: string, field: string, value: unknown) => void + onAppSetting: (app: string, field: string, value: unknown) => void busy: boolean }) { - // Options come from the extension's workflows AND the extension's own settings — + // Options come from the app's workflows AND the app's own settings — // both are operator knobs, rendered and edited the same way; the scope only // decides which edit map + setter a change routes to. const optionFields = [ - ...extension.workflows.flatMap((workflow) => + ...app.workflows.flatMap((workflow) => workflow.fields.map((f) => ({ scope: 'workflow' as const, kind: workflow.kind, f })), ), - ...extension.settings.map((f) => ({ scope: 'extension' as const, kind: extension.name, f })), + ...app.settings.map((f) => ({ scope: 'app' as const, kind: app.name, f })), ] const optionEdit = (o: (typeof optionFields)[number]) => - (o.scope === 'workflow' ? edits.workflowSettings : edits.extensionSettings)?.[o.kind]?.[o.f.name] + (o.scope === 'workflow' ? edits.workflowSettings : edits.appSettings)?.[o.kind]?.[o.f.name] const optionValue = (o: (typeof optionFields)[number]) => { const edit = optionEdit(o) return edit !== undefined ? edit : o.f.value @@ -2311,7 +2311,7 @@ function ExtensionPane({ const fields = optionFields .filter(({ scope, kind }) => scope === o.scope && kind === o.kind) .map(({ f }) => f) - const changes = (o.scope === 'workflow' ? edits.workflowSettings : edits.extensionSettings)?.[ + const changes = (o.scope === 'workflow' ? edits.workflowSettings : edits.appSettings)?.[ o.kind ] return isFieldVisible(o.f, fields, changes) @@ -2322,15 +2322,15 @@ function ExtensionPane({ '', ...new Set(visibleOptions.map(({ f }) => f.section).filter((label) => label !== '')), ] - const visibleExtensionFields = new Set( - visibleOptions.filter(({ scope }) => scope === 'extension').map(({ f }) => f.name), + const visibleAppFields = new Set( + visibleOptions.filter(({ scope }) => scope === 'app').map(({ f }) => f.name), ) const hiddenFieldErrors = optionFields.filter( ({ scope, f }) => - scope === 'extension' && fieldErrors[f.name] && !visibleExtensionFields.has(f.name), + scope === 'app' && fieldErrors[f.name] && !visibleAppFields.has(f.name), ) const setOption = (o: (typeof optionFields)[number], value: unknown) => - o.scope === 'workflow' ? onWorkflowField(o.kind, o.f.name, value) : onExtensionSetting(o.kind, o.f.name, value) + o.scope === 'workflow' ? onWorkflowField(o.kind, o.f.name, value) : onAppSetting(o.kind, o.f.name, value) // The control speaks strings; the override store keeps the declared type. // Clearing a secret's box records no edit, so the stored secret stays. An int // mid-edit that does not parse records nothing, so the box can be emptied and @@ -2345,13 +2345,13 @@ function ExtensionPane({
- {extension.description || 'Each stage runs as its own agent — set a default once per harness, override only where it matters.'} + {app.description || 'Each stage runs as its own agent — set a default once per harness, override only where it matters.'}
{optionFields.length > 0 && (
-
{extensionLabel(extension.name)} options
+
{appLabel(app.name)} options
{sectionLabels .map((sectionLabel) => ({ sectionLabel, @@ -2365,15 +2365,15 @@ function ExtensionPane({ {sectionLabel &&
{sectionLabel}
} {boolFields.length > 0 && ( -
+
{boolFields.map((o) => { const on = Boolean(optionValue(o)) const fieldError = - o.scope === 'extension' ? fieldErrors[o.f.name] : undefined + o.scope === 'app' ? fieldErrors[o.f.name] : undefined return (
{o.f.label} @@ -2394,7 +2394,7 @@ function ExtensionPane({ const override = optionEdit(o) const cur = optionValue(o) const fieldError = - o.scope === 'extension' ? fieldErrors[o.f.name] : undefined + o.scope === 'app' ? fieldErrors[o.f.name] : undefined const secret = o.f.type === 'secret' return ( )} - {extension.agents.length > 0 && ( + {app.agents.length > 0 && (
agents
harnessColor: Record allowedEfforts: string[] @@ -2479,7 +2479,7 @@ function AgentTable({
timeout
harness
- {extension.agents.map((a) => { + {app.agents.map((a) => { // The agent's declared harness supplies its inherited model. const famModel = harnessByName[a.default]?.model ?? a.model const modelOver: string | null = diff --git a/frontend/src/extensions/index.ts b/frontend/src/extensions/index.ts deleted file mode 100644 index fed49245..00000000 --- a/frontend/src/extensions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Import every bundled extension's UI module for its registration side effect. An -// extension contributes frontend by calling ``registerExtensionUI`` at import time; -// listing it here is what pulls it into the bundle. Adding an extension's UI is one -// line here — the shell (App, ExtensionDropdown, api/client) never learns its name. -import './ship/ui' -import './review/ui' diff --git a/frontend/src/extensions/registry.tsx b/frontend/src/extensions/registry.tsx deleted file mode 100644 index 944fa7a2..00000000 --- a/frontend/src/extensions/registry.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { ReactNode } from 'react' - -// The client-side extension-UI registry: how an extension contributes frontend. -// An extension calls ``registerExtensionUI`` once at import time with the routes -// its pages live at; the shell mounts them. Subnav tabs are not part of this — -// every extension declares those on its backend class, and the shell renders them -// from the roster. An extension that registers nothing still gets feed, settings, -// and usage from the shell for free — those are platform surfaces, not -// per-extension contributions. - -// One route an extension mounts. ``path`` is a wouter pattern under the router base -// (e.g. ``/ship`` or ``/ship/work-items/:slug``); ``render`` receives the matched -// params. -export interface ExtensionRoute { - path: string - render: (params: Record) => ReactNode -} - -export interface ExtensionUI { - // The extension's name — the same identifier the backend registry keys it by. - name: string - // The path the brand + dropdown land on (defaults to ``/``). - home?: string - routes: ExtensionRoute[] - // Where a feed row about one of this extension's subjects navigates. The shell knows - // an extension has subjects, never where its pages put them. - subjectPath?: (subject: { type: string; id: string }) => string | undefined - // Whether the persistent system-health strip (webhook + spend) rides above this - // extension's list and detail surfaces. Opt-in — an extension that doesn't track - // code hosts leaves it off and the band never renders. - systemStrip?: boolean -} - -// The switcher/display label for an extension — derived from its name, never -// declared: underscores become spaces, the lowercase house style stays. -export function extensionLabel(name: string): string { - return name.replace(/_/g, ' ') -} - -const REGISTRY = new Map() - -export function registerExtensionUI(ui: ExtensionUI): void { - REGISTRY.set(ui.name, ui) -} - -export function getExtensionUI(name: string): ExtensionUI | undefined { - return REGISTRY.get(name) -} - -// Every UI-contributing extension, in registration order. Available synchronously at -// import, so the shell mounts routes from this — direct URLs work on a cold load, not -// only once the async settings response arrives. -export function registeredExtensions(): ExtensionUI[] { - return [...REGISTRY.values()] -} - -// The home path the shell navigates to for an extension — its declared ``home`` or -// the conventional ``/``. Extensions with no UI contribution still resolve to a -// home so the dropdown and Esc have somewhere to land. -export function extensionHome(name: string): string { - return REGISTRY.get(name)?.home ?? `/${name}` -} - -// A wouter-style pattern (``/ship/work-items/:slug``) as a regex anchored to the -// whole path — for deciding which extension owns the current URL (its dropdown + -// accent). -function patternRegex(pattern: string): RegExp { - const source = pattern.replace(/:[^/]+/g, '[^/]+') - return new RegExp(`^${source}$`) -} - -// The registered extension that owns a location: the one whose home the path sits -// under, or whose routes match it. Reads the local registry (synchronous), so a direct -// load resolves the extension for its dropdown + accent without waiting on the settings -// fetch. Null on shell-owned paths (/usage, /events, /). -export function extensionOwning(location: string): string | null { - for (const ui of REGISTRY.values()) { - const home = ui.home ?? `/${ui.name}` - if (location === home || location.startsWith(`${home}/`)) return ui.name - if (ui.routes.some((route) => patternRegex(route.path).test(location))) return ui.name - } - return null -} diff --git a/frontend/src/lib/appColors.ts b/frontend/src/lib/appColors.ts new file mode 100644 index 00000000..4bc3a718 --- /dev/null +++ b/frontend/src/lib/appColors.ts @@ -0,0 +1,9 @@ +// App accent colours, handed out by registry order and rotated, so a +// newly-installed app gets the next accent with no per-name CSS. This is +// decoration (the dropdown trigger, the active subnav underline), so it draws from +// its own palette — never the harness ``--bucket-*`` tokens, which mean "which coding +// agent". The first entry keeps Ship's existing cyan so nothing shifts for it. +const APP_PALETTE = ['#4dd4f4', '#5eead4', '#a78bfa', '#f472b6', '#a3e635', '#fbbf24', '#60a5fa', '#f5a85c'] + +export const appAccent = (names: string[]): Record => + Object.fromEntries(names.map((name, i) => [name, APP_PALETTE[i % APP_PALETTE.length]!])) diff --git a/frontend/src/lib/extensionColors.ts b/frontend/src/lib/extensionColors.ts deleted file mode 100644 index e4ea0a4d..00000000 --- a/frontend/src/lib/extensionColors.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Extension accent colours, handed out by registry order and rotated, so a -// newly-installed extension gets the next accent with no per-name CSS. This is -// decoration (the dropdown trigger, the active subnav underline), so it draws from -// its own palette — never the harness ``--bucket-*`` tokens, which mean "which coding -// agent". The first entry keeps Ship's existing cyan so nothing shifts for it. -const EXTENSION_PALETTE = ['#4dd4f4', '#5eead4', '#a78bfa', '#f472b6', '#a3e635', '#fbbf24', '#60a5fa', '#f5a85c'] - -export const extensionAccent = (names: string[]): Record => - Object.fromEntries(names.map((name, i) => [name, EXTENSION_PALETTE[i % EXTENSION_PALETTE.length]!])) diff --git a/frontend/src/lib/feed.test.ts b/frontend/src/lib/feed.test.ts index e54d8c72..8b3c1335 100644 --- a/frontend/src/lib/feed.test.ts +++ b/frontend/src/lib/feed.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { eventLine } from './feed' // Ship's own registration, not a stand-in: its subjectPath is what makes a row navigate. -import '../extensions/ship/ui' +import '../apps/ship/ui' import type { FeedItem } from '../api/types' function event(fields: Partial): FeedItem { @@ -29,20 +29,20 @@ describe('eventLine', () => { ) }) - it("reads an extension's own milestone as its own word", () => { - const line = eventLine(event({ kind: 'merged', extension: 'ship' })) + it("reads an app's own milestone as its own word", () => { + const line = eventLine(event({ kind: 'merged', app: 'ship' })) expect(line.label).toBe('merged') expect(line.source).toBe('ship') expect(line.bucket).toBe('event-kind-audit') }) - it('names the subject as it showed itself, and links where the extension says', () => { + it('names the subject as it showed itself, and links where the app says', () => { const line = eventLine( event({ kind: 'workflow.finished', workflow: 'ship.build', - extension: 'ship', + app: 'ship', subjectType: 'work_item', subjectId: '42', subjectLabel: 'ENG-767', @@ -58,7 +58,7 @@ describe('eventLine', () => { event({ kind: 'workflow.running', workflow: 'ship.profile', - extension: 'ship', + app: 'ship', subjectType: 'project_repo', subjectId: '3', subjectLabel: 'acme/widget', @@ -70,11 +70,11 @@ describe('eventLine', () => { expect(line.path).toBeUndefined() }) - it('reads an unregistered extension without words or a page', () => { + it('reads an unregistered app without words or a page', () => { const line = eventLine( event({ kind: 'summarized', - extension: 'field_notes', + app: 'field_notes', subjectType: 'note', subjectId: '7', subjectLabel: 'note 7', diff --git a/frontend/src/lib/feed.ts b/frontend/src/lib/feed.ts index 459a7e56..a6818665 100644 --- a/frontend/src/lib/feed.ts +++ b/frontend/src/lib/feed.ts @@ -1,8 +1,8 @@ import type { FeedItem } from '../api/types' -import { getExtensionUI } from '../extensions/registry' +import { getAppUI } from '../apps/registry' // What a workflow doing something is called. The platform owns these words because it -// owns the lifecycle; an extension's own milestones are already named by their type. +// owns the lifecycle; an app's own milestones are already named by their type. const LIFECYCLE_VERBS: Record = { 'workflow.running': 'started', 'workflow.parked': 'waiting on you', @@ -17,9 +17,9 @@ export interface EventLine { // Who it happened to, as it showed itself. Empty for a row about nothing in // particular. subject: string - // The feed's source column — the workflow that ran, else the extension. + // The feed's source column — the workflow that ran, else the app. source: string - // Where the row navigates, when the extension has a page for its subject. + // Where the row navigates, when the app has a page for its subject. path?: string // Pill class, so the operator can scan a column of kinds by colour. bucket: string @@ -29,7 +29,7 @@ export function eventLine(event: FeedItem): EventLine { return { label: label(event), subject: event.subjectLabel ?? '', - source: localName(event.workflow) || event.extension || 'druks', + source: localName(event.workflow) || event.app || 'druks', path: subjectPath(event), bucket: isLifecycle(event) ? 'event-kind-agent' : 'event-kind-audit', } @@ -41,14 +41,14 @@ function label(event: FeedItem): string { const workflow = localName(event.workflow) return workflow ? `${workflow} ${verb}` : verb } - // An extension's milestone type is its own word ("merged", "needs_answers"), and an + // An app's milestone type is its own word ("merged", "needs_answers"), and an // unrecognised kind reads as itself rather than disappearing. return words(event.kind) } function subjectPath(event: FeedItem): string | undefined { - if (event.extension && event.subjectType && event.subjectId) { - const ui = getExtensionUI(event.extension) + if (event.app && event.subjectType && event.subjectId) { + const ui = getAppUI(event.app) return ui?.subjectPath?.({ type: event.subjectType, id: event.subjectId }) } return undefined diff --git a/frontend/src/lib/summary.ts b/frontend/src/lib/summary.ts index b765a654..c5c4e2b4 100644 --- a/frontend/src/lib/summary.ts +++ b/frontend/src/lib/summary.ts @@ -4,7 +4,7 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/ // A subject summary's scalar fields as label/text pairs — what the generic board // row and subject facts render. ``id`` and ``label`` are the row key and the title, -// not fields. Typed on the wire's base shape; the extension's extra fields are +// not fields. Typed on the wire's base shape; the app's extra fields are // what this walks. export function summaryEntries(summary: object): [string, string][] { const entries: [string, string][] = [] diff --git a/frontend/src/lib/useScreenWakeLock.ts b/frontend/src/lib/useScreenWakeLock.ts index 02917ad5..03381415 100644 --- a/frontend/src/lib/useScreenWakeLock.ts +++ b/frontend/src/lib/useScreenWakeLock.ts @@ -12,7 +12,7 @@ import { useEffect, useState } from 'react' * Returns the current state for surfaces that want to show an * indicator ("awake" pill in the system strip). Errors (API * unsupported, request refused) are stored on ``error`` instead of - * thrown — the rest of the extension keeps working, just without sleep + * thrown — the rest of the app keeps working, just without sleep * suppression. */ export function useScreenWakeLock(enabled: boolean = true): { diff --git a/frontend/src/pages/ExtensionHomePage.tsx b/frontend/src/pages/AppHomePage.tsx similarity index 80% rename from frontend/src/pages/ExtensionHomePage.tsx rename to frontend/src/pages/AppHomePage.tsx index ddd04437..b8c666d8 100644 --- a/frontend/src/pages/ExtensionHomePage.tsx +++ b/frontend/src/pages/AppHomePage.tsx @@ -10,39 +10,39 @@ import { PageHeader } from '../components/PageHeader' import { StatusGlyph } from '../components/StatusGlyph' import { summaryEntries } from '../lib/summary' -// The generic home an installed extension gets without shipping UI: its subject +// The generic home an installed app gets without shipping UI: its subject // boards, stacked. Each row is the platform status plus the summary's own fields — // the summary is the author's curated projection, so it is the row. interface Props { - extension: string + app: string description: string subjectTypes: string[] } -export function ExtensionHomePage({ extension, description, subjectTypes }: Props) { +export function AppHomePage({ app, description, subjectTypes }: Props) { return ( } + header={} > {subjectTypes.length === 0 ? ( ) : ( subjectTypes.map((subjectType) => ( - + )) )} ) } -function SubjectBoard({ extension, subjectType }: { extension: string; subjectType: string }) { +function SubjectBoard({ app, subjectType }: { app: string; subjectType: string }) { const [rows, setRows] = useState(null) const [, navigate] = useLocation() - useSSE(subjectApi.boardStream(extension, subjectType), { + useSSE(subjectApi.boardStream(app, subjectType), { handlers: useMemo( () => ({ snapshot: (data) => setRows((data as { rows: SubjectRow[] }).rows) }), [], @@ -63,7 +63,7 @@ function SubjectBoard({ extension, subjectType }: { extension: string; subjectTy
navigate(`/${extension}/${subjectType}/${row.summary.id}`)} + onClick={() => navigate(`/${app}/${subjectType}/${row.summary.id}`)} > {row.summary.label} diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index 4d7ff8d0..76677b33 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -5,10 +5,10 @@ import { useLocation, useSearch } from 'wouter' import { api } from '../api/client' import { useSSE } from '../api/sse' import type { FeedItem } from '../api/types' -import { BackToExtension } from '../components/BackToExtension' +import { BackToApp } from '../components/BackToApp' import { EmptyState } from '../components/EmptyState' import { Page } from '../components/Page' -import { registeredExtensions } from '../extensions/registry' +import { registeredApps } from '../apps/registry' import { eventLine } from '../lib/feed' import { relTimeFromIso } from '../lib/format' import { useFormatters } from '../lib/preferences' @@ -26,8 +26,8 @@ const INITIAL_FETCH = 200 * fresh via the SSE stream. Inserts dedupe by ``id`` so the boundary * between the initial fetch and the SSE backfill doesn't double-render. * - * The feed spans every extension by default. The extension filter lives - * in the URL query (``/events?extension=ship``, absent = all) so the view + * The feed spans every app by default. The app filter lives + * in the URL query (``/events?app=ship``, absent = all) so the view * is shareable, survives a refresh, and moves with back/forward. */ export function EventsPage() { @@ -35,16 +35,16 @@ export function EventsPage() { const search = useSearch() const { absTimeCompact } = useFormatters() - // Null = every extension plus anything unscoped. Both the page fetch and the + // Null = every app plus anything unscoped. Both the page fetch and the // stream take the same filter, so a narrowed view stays narrow as it streams. - const filter = new URLSearchParams(search).get('extension') + const filter = new URLSearchParams(search).get('app') // Initial backfill. The SSE stream's first tick will also send a // window; the dedupe in ``mergeEvents`` covers the overlap so the // operator never sees a row twice. const initial = useQuery({ queryKey: ['events', 'initial', filter], - queryFn: () => api.listEvents({ limit: INITIAL_FETCH, extension: filter ?? undefined }), + queryFn: () => api.listEvents({ limit: INITIAL_FETCH, app: filter ?? undefined }), // The SSE feed owns freshness — don't refetch this on focus. staleTime: Infinity, }) @@ -71,7 +71,7 @@ export function EventsPage() { }, []) const sseHandlers = useMemo(() => ({ message: handleMessage }), [handleMessage]) - useSSE(`/api/events/stream${filter ? `?extension=${encodeURIComponent(filter)}` : ''}`, { + useSSE(`/api/events/stream${filter ? `?app=${encodeURIComponent(filter)}` : ''}`, { handlers: sseHandlers, }) @@ -84,7 +84,7 @@ export function EventsPage() { navigate(name ? `/events?extension=${encodeURIComponent(name)}` : '/events')} + onPick={(name) => navigate(name ? `/events?app=${encodeURIComponent(name)}` : '/events')} /> ) @@ -130,7 +130,7 @@ function EventsHeader({ }: { count: number | null filter: string | null - onPick: (extension: string | null) => void + onPick: (app: string | null) => void }) { return (
@@ -147,31 +147,31 @@ function EventsHeader({ · capped at {FEED_CAP}
- +
- +
) } -function ExtensionFilter({ +function AppFilter({ filter, onPick, }: { filter: string | null - onPick: (extension: string | null) => void + onPick: (app: string | null) => void }) { // The local registry paints the pills on a cold load; the roster adds the - // extensions that ship no UI — builtins included, the platform's own chores + // apps that ship no UI — builtins included, the platform's own chores // and webhooks emit events too. Same query key as the shell, so this reads // its cache rather than issuing a request of its own. const installed = useQuery({ - queryKey: ['extensions'], - queryFn: api.listExtensions, + queryKey: ['apps'], + queryFn: api.listApps, staleTime: 60_000, }) const options = useMemo(() => { - const names = new Set(registeredExtensions().map((e) => e.name)) + const names = new Set(registeredApps().map((e) => e.name)) for (const entry of installed.data ?? []) names.add(entry.name) // Leading null is the unfiltered view the bare URL lands on. return [null, ...names] diff --git a/frontend/src/pages/SubjectPage.tsx b/frontend/src/pages/SubjectPage.tsx index 54128ed4..e859517b 100644 --- a/frontend/src/pages/SubjectPage.tsx +++ b/frontend/src/pages/SubjectPage.tsx @@ -15,12 +15,12 @@ import { StatusGlyph } from '../components/StatusGlyph' import { relTimeFromIso } from '../lib/format' import { summaryEntries } from '../lib/summary' -// The generic subject page any installed extension gets without shipping UI: the +// The generic subject page any installed app gets without shipping UI: the // summary as facts, the run timeline, the newest run's transcript, and the gate // controls when a run parks on the operator. interface Props { - extension: string + app: string subjectType: string subjectId: string } @@ -28,15 +28,15 @@ interface Props { const isActiveRun = (run: RunSummary) => run.state === 'running' || run.state === 'parked' || run.state === 'scheduled' -export function SubjectPage({ extension, subjectType, subjectId }: Props) { +export function SubjectPage({ app, subjectType, subjectId }: Props) { const queryClient = useQueryClient() const queryKey = useMemo( - () => ['subject', extension, subjectType, subjectId] as const, - [extension, subjectType, subjectId], + () => ['subject', app, subjectType, subjectId] as const, + [app, subjectType, subjectId], ) const query = useQuery({ queryKey, - queryFn: () => subjectApi.read(extension, subjectType, subjectId), + queryFn: () => subjectApi.read(app, subjectType, subjectId), }) // Push-driven cache, same as every detail page: the stream re-emits the whole @@ -47,7 +47,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) { }, [queryClient, queryKey], ) - useSSE(subjectApi.stream(extension, subjectType, subjectId), { + useSSE(subjectApi.stream(app, subjectType, subjectId), { handlers: useMemo(() => ({ snapshot: patchSnapshot }), [patchSnapshot]), onError: () => { queryClient.invalidateQueries({ queryKey }).catch(() => {}) @@ -65,7 +65,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) { const runs = [...data.timeline].reverse() const crumb = (
- + ← {label}
@@ -86,7 +86,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) { ) : ( runs.map((run, index) => ( - + )) )} @@ -94,11 +94,11 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) { } function RunBlock({ - extension, + app, run, withTranscript, }: { - extension: string + app: string run: RunSummary withTranscript: boolean }) { @@ -135,7 +135,7 @@ function RunBlock({ )} {withTranscript && call && ( )} diff --git a/frontend/src/pages/UsagePage.tsx b/frontend/src/pages/UsagePage.tsx index e907c0df..bb7240b0 100644 --- a/frontend/src/pages/UsagePage.tsx +++ b/frontend/src/pages/UsagePage.tsx @@ -1,4 +1,4 @@ -import { BackToExtension } from '../components/BackToExtension' +import { BackToApp } from '../components/BackToApp' import { Page } from '../components/Page' import { UsagePanel } from '../components/UsagePanel' @@ -10,14 +10,14 @@ import { UsagePanel } from '../components/UsagePanel' * Thin shell — the actual layout, data fetching, refresh button, * and parse-failure disclosure all live in :class:`UsagePanel`, so * the component stays reusable if we ever want to embed the same - * card somewhere else. The ``BackToExtension`` link is the visible + * card somewhere else. The ``BackToApp`` link is the visible * counterpart to the global Esc handler in ``AppShell``. */ export function UsagePage() { return (
- +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 58eb57c0..68ded37d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,6 +1,6 @@ /* ============================================================ druks dashboard — NOC-style operator UI - Dark extension mandatory. Dense. Monospace where data matters. + Dark app mandatory. Dense. Monospace where data matters. ============================================================ */ :root { @@ -16,7 +16,7 @@ /* Text */ --text: #f0f2f7; /* The whole secondary tier is lifted: labels (dim) and their values - (mid) were the dominant chrome of the extension yet sat at barely-readable + (mid) were the dominant chrome of the app yet sat at barely-readable contrast. Hierarchy preserved: text > mid > dim > faint — but the floor (faint) now sits where dim used to. */ --text-mid: #b4bccd; @@ -27,7 +27,7 @@ --bucket-run: #4dd4f4; /* cyan — in-progress */ --bucket-human: #f4ad63; /* warm orange — waiting on human (loud) */ --bucket-dead: #ef4f5b; /* red — blocked / dead */ - --bucket-plan: #5eead4; /* teal — plan extension accent */ + --bucket-plan: #5eead4; /* teal — plan app accent */ /* Decorative accent hues for non-status chrome (thinking rows, event kinds, target/queue chips, tab & settings accents). Their own tokens, @@ -62,7 +62,7 @@ --row-grid-item-prefix: 60px 110px 130px 60px; /* Density (overridden via data-density). The default sits at a - * "compact but readable" 40px — every list across the extension uses + * "compact but readable" 40px — every list across the app uses * ``var(--row-h)`` for ``min-height`` so all pages render at the * same vertical rhythm. Earlier the default was 28px and most * row rules hardcoded their own 30-44px, which drifted apart @@ -180,11 +180,11 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: -webkit-mask-position: center; } -/* ---- Extension dropdown --------------------------------- */ +/* ---- App dropdown --------------------------------- */ .appbar-sep { font-size: 14px; color: var(--text-faint); padding: 0 2px; align-self: center; } -.extension-dd { position: relative; align-self: center; } -.extension-dd-trigger { +.app-dd { position: relative; align-self: center; } +.app-dd-trigger { display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; font-size: 13px; @@ -195,14 +195,14 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: transition: all 100ms; letter-spacing: 0.01em; } -.extension-dd-trigger:hover { border-color: var(--border-loud); background: var(--surface-2); } +.app-dd-trigger:hover { border-color: var(--border-loud); background: var(--surface-2); } /* The trigger's accent left rule is set inline from the registry-order - palette (see lib/extensionColors) — no per-extension-name rule. */ -.extension-dd-glyph { font-size: 14px; opacity: 0.9; display: inline-flex; align-items: center; } -.extension-dd-label { font-weight: 500; } -.extension-dd-caret { font-size: 9px; opacity: 0.6; padding-left: 4px; } + palette (see lib/appColors) — no per-app-name rule. */ +.app-dd-glyph { font-size: 14px; opacity: 0.9; display: inline-flex; align-items: center; } +.app-dd-label { font-weight: 500; } +.app-dd-caret { font-size: 9px; opacity: 0.6; padding-left: 4px; } -.extension-dd-menu { +.app-dd-menu { position: absolute; top: 100%; left: 0; margin-top: 6px; min-width: 280px; @@ -218,7 +218,7 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: translateY(0); } } -.extension-dd-item { +.app-dd-item { display: grid; align-items: center; grid-template-columns: 22px 1fr auto; gap: 10px; @@ -229,14 +229,14 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: background: transparent; transition: background 80ms; } -.extension-dd-item:hover { background: var(--surface-2); } -.extension-dd-item.active { background: var(--surface-3); } -.extension-dd-item-glyph { font-size: 14px; opacity: 0.85; text-align: center; display: inline-flex; align-items: center; justify-content: center; } +.app-dd-item:hover { background: var(--surface-2); } +.app-dd-item.active { background: var(--surface-3); } +.app-dd-item-glyph { font-size: 14px; opacity: 0.85; text-align: center; display: inline-flex; align-items: center; justify-content: center; } /* The active item's accent is set inline from the registry-order palette. */ -.extension-dd-item-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.extension-dd-item-label { font-size: 13px; color: var(--text); letter-spacing: 0.01em; } -.extension-dd-item-desc { font-size: 10.5px; line-height: 1.35; } -.extension-dd-item-kbd { font-size: 10px; opacity: 0.6; } +.app-dd-item-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.app-dd-item-label { font-size: 13px; color: var(--text); letter-spacing: 0.01em; } +.app-dd-item-desc { font-size: 10.5px; line-height: 1.35; } +.app-dd-item-kbd { font-size: 10px; opacity: 0.6; } /* ---- Drill-down subnav ----------------------------------- */ .appbar-subnav { @@ -254,7 +254,7 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: transition: color 80ms, border-color 80ms; } .subnav-tab:hover { color: var(--text-mid); } -/* The active underline colour is set inline from the extension's registry-order +/* The active underline colour is set inline from the app's registry-order accent; this is the fallback before it resolves. */ .subnav-tab.active { color: var(--text); border-bottom-color: var(--text); } @@ -276,7 +276,7 @@ button { background: none; border: none; color: inherit; cursor: pointer; font: } -.extension-main { +.app-main { height: calc(100vh - 44px); display: flex; flex-direction: column; @@ -926,7 +926,7 @@ a.row-repo:hover { color: var(--bucket-run); text-decoration: underline; } /* ============================================================ - Settings redesign — rail + per-extension agent tables + inheritance + Settings redesign — rail + per-app agent tables + inheritance ============================================================ */ .set-skill-error { color: var(--bucket-dead); font-size: 12px; font-family: var(--font-mono); } @@ -960,12 +960,12 @@ a.row-repo:hover { color: var(--bucket-run); text-decoration: underline; } .set-navitem { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 4px; color: var(--text-mid); font-size: 13px; text-align: left; width: 100%; border: 1px solid transparent; transition: background 100ms, color 100ms; cursor: pointer; background: none; } .set-navitem .ni-glyph { width: 16px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); } .set-navitem .ni-label { flex: 1; letter-spacing: 0.01em; } -.set-navitem.is-extension .ni-label { text-transform: capitalize; } +.set-navitem.is-app .ni-label { text-transform: capitalize; } .set-navitem:hover { background: var(--surface-2); color: var(--text); } .set-navitem.active { background: var(--surface-3); color: var(--text); border-color: var(--border); } .set-navitem.active .ni-glyph { color: var(--accent); } -.set-navitem.is-extension.active { border-left: 2px solid var(--accent); } -.set-navitem.is-extension.active .ni-glyph { color: var(--accent); } +.set-navitem.is-app.active { border-left: 2px solid var(--accent); } +.set-navitem.is-app.active .ni-glyph { color: var(--accent); } .set-content { display: flex; flex-direction: column; min-height: 0; overflow: auto; } .set-pane { padding: 22px 26px 30px; display: flex; flex-direction: column; gap: 22px; } @@ -1046,11 +1046,11 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; .set-switch.on::after { transform: translateX(16px); background: var(--accent); } .set-switch:disabled { opacity: 0.5; cursor: default; } -.set-extension-toggles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } -.set-extension-toggle { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); } -.set-extension-toggle .mt-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.set-extension-toggle .mt-name { font-family: var(--font-mono); font-size: 12.5px; color: var(--text); } -.set-extension-toggle .mt-desc { font-size: 11.5px; color: var(--text-dim); line-height: 1.4; } +.set-app-toggles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.set-app-toggle { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); } +.set-app-toggle .mt-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.set-app-toggle .mt-name { font-family: var(--font-mono); font-size: 12.5px; color: var(--text); } +.set-app-toggle .mt-desc { font-size: 11.5px; color: var(--text-dim); line-height: 1.4; } .set-table { border: 1px solid var(--border); border-radius: 7px; overflow: hidden; } .set-thead, .set-trow { display: grid; align-items: center; grid-template-columns: minmax(132px, 1.4fr) minmax(120px, 1.2fr) 86px 86px 72px; gap: 8px; padding: 0 12px; } @@ -1276,7 +1276,7 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; .login-window-canvas canvas { max-width: 100%; max-height: 100%; object-fit: contain; } @media (max-width: 720px) { - .set-extension-toggles { grid-template-columns: 1fr; } + .set-app-toggles { grid-template-columns: 1fr; } .svc-grid { grid-template-columns: 1fr; } .set-thead { display: none; } .set-trow { grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px 14px; } @@ -1335,12 +1335,12 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; * * Confidence palette and review-decision palette are dedicated colors; * see :root above. The bucket/outcome palettes are not reused here so a - * green chip can't accidentally mean two things across the extension. + * green chip can't accidentally mean two things across the app. * --------------------------------------------------------------------- */ /* Markdown content rendered by react-markdown — for finding bodies, * review bodies, and result summaries. Scoped via .markdown-content so - * we don't accidentally restyle the rest of the extension. Generous prose + * we don't accidentally restyle the rest of the app. Generous prose * typography (line-height 1.65, ~72ch measure) because operators * spend minutes reading these, not seconds scanning rows. */ .markdown-content { @@ -1921,7 +1921,7 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; text-transform: none; } -/* Extension filter — one global log, so the pills narrow the view rather than +/* App filter — one global log, so the pills narrow the view rather than switch between feeds. Options come from the registry; no name lands here. */ .events-filter { display: flex; flex-wrap: wrap; gap: 6px; @@ -2017,9 +2017,9 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; } /* ============================================================ - Back-to-extension link — extension-independent detail pages (/usage, /events) + Back-to-app link — app-independent detail pages (/usage, /events) ============================================================ */ -.back-to-extension { +.back-to-app { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; letter-spacing: 0.04em; @@ -2030,7 +2030,7 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; background: var(--surface); transition: color 80ms, border-color 80ms, background 80ms; } -.back-to-extension:hover { +.back-to-app:hover { color: var(--text-mid); border-color: var(--border-loud); background: var(--surface-2); @@ -2136,8 +2136,8 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; /* ============================================================ Projects config surface (projects.css) — full-width column of - project cards: a primary extension + sibling repos a build operates - on. Calm, dense, deliberate. (Appbar/strip chrome is the extension's.) + project cards: a primary app + sibling repos a build operates + on. Calm, dense, deliberate. (Appbar/strip chrome is the app's.) ============================================================ */ /* Full-width content inside the shared shell, matching the data pages' ~20px inset — no bespoke max-width cap. */ @@ -2463,7 +2463,7 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; @keyframes landing-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } } /* ============================================================ - GENERIC SUBJECT PAGES — the floor an installed extension gets + GENERIC SUBJECT PAGES — the floor an installed app gets ============================================================ */ .subject-row { grid-template-columns: 14px minmax(60px, auto) 1fr auto; } .subject-row-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; } diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index cd85c304..dd755b2d 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -10,7 +10,7 @@ /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, - /* Mirrors vite.config.ts's resolve.alias, so a bundled extension importing + /* Mirrors vite.config.ts's resolve.alias, so a bundled app importing '@druks/ui' typechecks against the same shim the import map serves. */ "paths": { "@druks/ui": ["./src/runtime/druks-ui.ts"] }, "verbatimModuleSyntax": true, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 579fc96a..7e2c6839 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -28,7 +28,7 @@ const shimUrl = (file: string) => new URL(`./src/runtime/${file}`, import.meta.u // The rollup entry name for a shim, and what transformIndexHtml matches on. const shimEntry = (file: string) => `runtime-${file.replace(/\.[jt]s$/, '')}` -// Bundled extensions import '@druks/ui' as an installed app does, so breaking +// Bundled apps import '@druks/ui' as an installed app does, so breaking // the lent surface reddens the shell's own build first. Exported for vitest. export const shellAlias = { '@druks/ui': fileURLToPath(shimUrl(SHARED_MODULES['@druks/ui']!)), @@ -68,7 +68,7 @@ export default defineConfig({ server: { port: 5173, proxy: { - // FastAPI serves the API (and installed extension dists under /app); + // FastAPI serves the API (and installed app dists under /app); // Vite proxies during dev so the SPA can hit both same-origin. '/api': { target: 'http://127.0.0.1:8001', diff --git a/pyproject.toml b/pyproject.toml index b120e214..f8b7765b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ requires-python = ">=3.11" dependencies = [ "asyncssh>=2.22", "drukbox-python-sdk>=0.0.6", - # 0.138 adds app.frontend(), which serves an extension's shipped dist/. + # 0.138 adds app.frontend(), which serves an app's shipped dist/. "fastapi>=0.138.0", "githubkit[auth-app]>=0.15.5", "httpx>=0.28.0", @@ -62,15 +62,15 @@ druks = "druks.cli:main" [project.entry-points.pytest11] druks = "druks.testing" -# Extensions are discovered here. An extension (in-tree today, a separate package -# tomorrow) registers one Extension subclass; the platform loads them at startup and -# walks each one's package for capabilities. Adding an extension is one entry, not +# Apps are discovered here. An app (in-tree today, a separate package +# tomorrow) registers one App subclass; the platform loads them at startup and +# walks each one's package for capabilities. Adding an app is one entry, not # edits across the platform wiring. -[project.entry-points."druks.extensions"] -core = "druks.core.extension:Core" -review = "druks.contrib.review.extension:Review" -ship = "druks.contrib.ship.extension:Ship" -usage = "druks.usage.extension:Usage" +[project.entry-points."druks.apps"] +core = "druks.core.app:Core" +review = "druks.contrib.review.app:Review" +ship = "druks.contrib.ship.app:Ship" +usage = "druks.usage.app:Usage" [build-system] requires = ["hatchling"] @@ -123,7 +123,7 @@ include = ["backend"] # its loose mocks (call_args, SimpleNamespace-as-Request) are false-positive noise # that drowns the real signal in backend/druks. exclude = ["backend/tests"] -# The proof extension (backend/tests/druks-field_notes) is a standalone package; its +# The proof app (backend/tests/druks-field_notes) is a standalone package; its # root is on the path so its intra-package imports and the tests that load it resolve. extraPaths = ["backend", "backend/tests", "backend/tests/druks-field_notes"] typeCheckingMode = "standard" diff --git a/scripts/import_browser_session.py b/scripts/import_browser_session.py index a372628a..08490d81 100755 --- a/scripts/import_browser_session.py +++ b/scripts/import_browser_session.py @@ -100,7 +100,7 @@ def main() -> int: if args.name not in {row["name"] for row in sessions if row["isDeclared"]}: raise RuntimeError( f"Browser session {args.name!r} is not declared by any installed " - "extension; declare it on the extension class first." + "app; declare it on the App class first." ) with tempfile.TemporaryDirectory(prefix="druks-browser-session-") as temporary_directory: