--- name: add-component description: Use when adding a new infrastructure component (worker, scheduler, database, redis, ingress, observability) to the Aegis Stack framework itself, or adding a new variant axis (like worker_backend or database_engine) to an existing component. Covers the plugin-spec registry, dependency resolution, copier questions, generation/update plumbing, template, test, docs, and i18n files that must change together. --- # Add component Adds a new infrastructure component (background worker, scheduler, database, redis, ingress, observability) to the framework so generated projects can select it via `aegis init` or add it later via `aegis add`, OR adds a new variant axis to an existing component (a second/third backend choice like `worker_backend` on `worker` or `database_engine` on `database`). Traced from `worker` (with its `worker_backend` axis: arq/dramatiq/taskiq) and `database` (with its `database_engine` axis: sqlite/postgres). ## When to use Use for either: - A brand-new optional component (infrastructure, not business logic) behind its own `include_` copier question, addable/removable via `aegis add` / `aegis remove`. - A new variant axis on an existing component: a second copier question (`_`) that changes which files render for that component, following the `worker_backend` / `database_engine` pattern. Do NOT use for adding a **service** (business logic: auth, AI, blog, finance, payment, comms, insights) - services live in `aegis/core/services.py` and share the same `PluginSpec` shape but a different file footprint and CLI surface (`aegis add-service`, not `aegis add`); use the `add-service` skill instead. Do NOT use for changing behavior inside an existing component with no new question or file gating; that is ordinary feature work. ## Files that change Registry (`ComponentSpec` is a thin alias of `PluginSpec` pinned to `kind=COMPONENT`; see `aegis/core/plugins/spec.py`): - `aegis/core/components.py`: add a `ComponentSpec` entry to the `COMPONENTS` dict. Set `type` (`ComponentType.INFRASTRUCTURE`; `ComponentType.CORE` is reserved for `backend`/`frontend` in `CORE_COMPONENTS` - core components are never prompted, never removable, and skipped by cleanup entirely), `description`/`long_description`, `required_components` (hard deps, e.g. worker requires `["redis"]`), `recommended_components`, `pyproject_deps`, `docker_services` (compose service names this component owns, used by stack-generation tests), `template_files` (display list), `marker_path` (the path used to detect the component on disk - for `reconcile_answers_from_disk` and dev-mode cleanup context), `docs_path` (see docs section below), and `migrations=[...]` only if the component owns tables (see `SCHEDULER_MIGRATION` in `aegis/core/migration_generator.py`: the spec names the revision and its Postgres schema, nothing more - what the revision creates comes from the component's SQLModel classes, for every non-memory backend). - `aegis/core/components.py`: `files=FileManifest(primary=[...], extras={...})` - every path the component owns. `primary` is the always-on add/init base; `aegis/core/post_gen_tasks.py`'s `get_component_file_mapping()` derives from every spec's manifest via `compute_file_mapping()` (`aegis/core/file_manifest.py`), so there is no separate hand-maintained mapping to edit. Use `extras` for file groups that only render real content for a specific variant value (e.g. scheduler's `"scheduler_persistence"` group, gated on `scheduler_backend != "memory"`) - keeping them out of `primary` stops `aegis add` from writing 0-byte stubs when the parent variant doesn't need them; `aegis remove` still deletes them via `get_component_files(..., full=True)`. - `aegis/constants.py`: `ComponentNames` - add ` = ""` and append it to `INFRASTRUCTURE_ORDER` (controls interactive prompt ordering). `AnswerKeys` - add ` = "include_"`. For a new variant axis, add the axis key (e.g. `_ = "_"`) and, if the axis has a fixed enum of values, a dedicated `Backends`/`Type` class near `WorkerBackends` / `StorageBackends` with an `ALL` list. - `aegis/core/dependency_resolver.py`: no edits needed for a plain component - it reads `ComponentSpec.requires`/`.recommends`/`.conflicts` generically via `COMPONENTS`. Only add logic here if the new component needs a resolution rule beyond simple hard-dependency (worker -> redis is handled by `required_components` alone, no custom code). - `copier.yml` (repo root, not under `templates/`): add the `include_` bool question. For a variant axis, add `_` with `choices:` and `when: "{{ include_ }}"` - UNLESS the value must survive even when the component is off (compare `database_engine`, which has **no** `when` clause because templates gate on it regardless of `include_database`, e.g. a persistent scheduler needs to know the engine before database is even selected). - `aegis/templates/copier-aegis-project/{{ project_slug }}/.copier-answers.yml.jinja`: add `include_: {{ include_ }}`. For an axis gated by `when`, adding the line here is NOT required for correctness - Copier omits `when`-gated fields from the rendered answers file (observed for `worker_backend`, `scheduler_backend`, `include_oauth`; none of them appear in this file today), and `copier_manager.py`'s post-generation patch (below) backfills every `copier_data` key Copier dropped. Add the line anyway for axes that are NOT `when`-gated (like `database_engine`, which IS listed here) since those are the ones templates read unconditionally. Generation and update plumbing (thread the new `AnswerKeys.` / `AnswerKeys._` answer key through every one of these; a component that skips them generates fine at `aegis init` but breaks `aegis add`/`aegis remove`/`aegis update` on an existing project): - `aegis/core/template_generator.py` (`TemplateGenerator.__init__` and `get_template_context()`): extract the axis value from bracket syntax (`worker[dramatiq]`) the same way `self.worker_backend` is derived from `extract_engine_info(component)`, default it (`WorkerBackends.ARQ`), and add it to the returned context dict. Also branch in the pyproject-deps loop (`base_name == ComponentNames.WORKER` block) if different variant values pull different pip packages. - `aegis/core/copier_manager.py` (`generate_with_copier`): add `AnswerKeys.: template_context[AnswerKeys.] == "yes"` (or `.get(..., default)` for optional flags) to the `copier_data` dict passed to `run_copy`. This is also where the backfill described above lives - every non-underscore `copier_data` key gets written into `.copier-answers.yml` after generation if Copier dropped it, so this dict is the actual source of truth for what ends up in the answers file, not the `.copier-answers.yml.jinja` list. - `aegis/core/manual_updater.py` (`ManualUpdater`): `get_copier_defaults()` (from `aegis/core/component_files.py`) merges copier.yml defaults under the persisted answers at construction time, so a variant axis with a `default:` in copier.yml needs no extra wiring here for the *default* case. But `add_component`/`remove_component` must reference `AnswerKeys._` explicitly anywhere the render context needs it - as of this trace, `manual_updater.py` has **zero** references to `worker_backend`, which is why `aegis add worker[dramatiq]` on an existing project silently ignores the bracket and always adds arq (the default). Don't repeat this gap for a new axis: thread it through `add_component`'s context building, mirroring how `aegis/commands/add.py` threads `AnswerKeys.DATABASE_ENGINE`/`AnswerKeys.SCHEDULER_BACKEND` into `component_data` before calling `updater.add_component(component, component_data)`. - `aegis/commands/add.py` (`add_command`): parse the variant out of bracket syntax via `extract_engine_info(comp)` for the new component (mirrors the scheduler-backend block), validate it against the axis's `ALL` list, and populate `update_data[AnswerKeys._]` / the per-component `component_data` dict passed to `updater.add_component(...)`. - `aegis/commands/update.py`: no per-component AnswerKeys threading exists here today (verify this still holds before skipping it) - `aegis update` runs the full Copier update flow and re-derives context from the project's own (reconciled) answers file, not from a hand-maintained key list. - `aegis/cli/guided.py` / `aegis/cli/interactive.py`: add a `choose__backend(self) -> str` method (on the guided-setup UI class) following `choose_worker_backend()` / `choose_database_engine()` - a list of `_Choice(value, label, description)` tuples fed to `self._select(...)` with an i18n-backed prompt string (`_g("prompt._backend", "...")`) and per-choice description keys (`choice..`). Template side (`aegis/templates/copier-aegis-project/{{ project_slug }}/`): - `app/components//`: the component's own package. - `docker-compose.yml.jinja` / `.dev` / `.prod`: gate services behind `{%- if include_ %}` (full-body wrap, never a Jinja expression inside a YAML value Copier can't parse standalone). These compose templates carry **no inline comments** - which components a given project selects varies per stack, so a comment written for one combination misleads for another; state conditions through the Jinja gate itself, not prose beside it. - `Makefile.jinja`: gate any new `make` targets the component needs (compare how `worker`/`scheduler` targets are conditioned). - `app/components/backend/startup/component_health.py.jinja`: register the health check behind `{%- if include_ %}`, following the existing `include_worker` / `include_database` blocks. For a variant axis, nest a second `{%- if _ == "value" %}` inside. - `pyproject.toml.jinja`: gate the component's `pyproject_deps` behind `{%- if include_ %}`. For a variant axis with per-value deps (arq vs dramatiq vs taskiq each pull different packages), branch on the axis value inside that same gate. - `docs/components//index.md` (+ `configuration.md`, `examples.md`, `extras/.md` as applicable - this is the multi-page convention `worker` and `scheduler` use; a single `docs/components/.md` file also satisfies the docs_path test, matching `database`/`scheduler`'s top-level file). Wire the new pages into the root `mkdocs.yml` nav under the `Components:` block. - Tests (generated-project template tests): `tests/components/test_.py`, and API/frontend tests if the component ships routes or dashboard cards/modals (`app/components/frontend/dashboard/cards/_card.py`, `modals/_modal.py`, exported from the matching `__init__.py.jinja` files, same wiring shape as the service card/modal pattern in `add-service`). Cross-cutting: - Shared files need **no registration**. A shared file is any template path no component/service `FileManifest` claims; `aegis add`/`remove` finds it by rendering the tree at the old and new answers and diffing. Put `{% if include_ %}` conditionals in `docker-compose.yml`, `pyproject.toml`, `Makefile`, `component_health.py`, `app/core/config.py`, `.env.example` — or in a brand-new shared file — and it is handled automatically. There is no list to add it to. - If the component needs a whole file of its own, put it in the spec's `FileManifest` instead (see "Files this component owns" above). That is the only file declaration there is. - `tests/core/test_shared_scope_completeness.py` fails, naming the file, if a stack-dependent file somehow ends up handled by nothing. - `docs/components//index.md` (or `.md`) is pinned by `tests/core/test_spec_docs_paths.py` once `docs_path` is set on the spec - it accepts either `docs/.md` or `docs//index.md`. Leave `docs_path=""` until the page exists. i18n: - `aegis/i18n/locales/en.py`: add `"component."` (short description, shown by `aegis add --help` component listings and the guided setup) and `"component..long"` (long-form description) with real English strings. For a variant axis, also add `"prompt._"` and one `"choice.."` key per choice (see `choice.worker.arq` / `choice.worker.dramatiq` / `choice.worker.taskiq`). - `aegis/i18n/locales/{de,es,fr,ja,ko,ru,zh,zh_hant}.py`: add the SAME keys to every other locale, using the English string as a placeholder. This is required for parity: `tests/core/test_i18n.py` asserts every locale carries the same keys as English (one `test__has_all_keys` per locale) and fails naming the missing key otherwise. Real translation is a separate translator-reviewed pass; do not attempt it here, but the keys must exist now. Tests (this repo's own CLI/core test suite): - `tests/cli/test_stack_generation.py`: add a `StackCombination` entry to `STACK_COMBINATIONS` (mirrors the `worker`/`scheduler`/`full` entries) with `expected_files`, `expected_docker_services`, and `expected_pyproject_deps`. - `tests/cli/conftest.py`: add a `_with_` entry to `NAMED_PROJECT_SPECS` for each new variant value (see `base_with_worker_taskiq` / `base_with_worker_dramatiq`) so downstream tests copy a cached project instead of regenerating one. - `tests/cli/test_add_command.py`: add coverage for `aegis add ` (and, if applicable, `aegis add []`) following the existing `test_add_scheduler_with_backend_flag_sqlite` / `test_add_worker_auto_adds_redis` style. There is no dedicated "importable regen guard" test for components analogous to `add-service`'s `SERVICE_INVOCATIONS` - add explicit add-path assertions here instead so a missing `FileManifest` entry surfaces as a test failure rather than a stub file in the wild. - `tests/core/test_spec_docs_paths.py` and `tests/core/test_i18n.py` need no edits - they parametrize over `COMPONENTS`/`SERVICES` and the locale catalogs automatically; they just need to pass. ## Procedure 1. Write the failing test first: add a `StackCombination` to `tests/cli/test_stack_generation.py::STACK_COMBINATIONS` asserting the new component's expected files/docker services/deps. Confirm it fails for the right reason (component doesn't exist yet). 2. Add `ComponentNames.` (+ `INFRASTRUCTURE_ORDER` entry) and `AnswerKeys.` in `aegis/constants.py`. For a variant axis, add the axis key and its `Backends`-style enum class. 3. Add the `ComponentSpec` entry in `aegis/core/components.py`: identity fields, `required_components`, `pyproject_deps`, `docker_services`, `marker_path`, `files=FileManifest(primary=[...], extras={...})`, and `migrations=[...]` if it owns tables. 4. Add the `include_` question to `copier.yml` (and the axis question with `choices`/`when` if applicable). Add the corresponding line to `.copier-answers.yml.jinja` for any NOT-`when`-gated field; `when`-gated fields are backfilled automatically (see Files that change). 5. Thread the new answer key(s) through the generation/update plumbing: `template_generator.py`, `copier_manager.py`, `manual_updater.py`, `aegis/commands/add.py` (bracket parsing + `component_data`), and the guided/interactive prompt classes (`guided.py`/`interactive.py`). 6. Build the template package: `app/components//`, docker-compose gating (no inline comments), `Makefile.jinja` targets, `pyproject.toml.jinja` deps, and the health check registration in `component_health.py.jinja`. 7. If the component ships dashboard UI, wire the card/modal and their `__init__.py.jinja` exports (mirror the service pattern in `add-service`). 8. Add the i18n keys (`component.`, `component..long`, plus `prompt._` / `choice..` for a variant axis) to `en.py`, then stub the same keys into every other locale for parity; `tests/core/test_i18n.py` fails otherwise. 9. Add the `tests/cli/test_stack_generation.py` combination (from step 1, now passing), the `tests/cli/conftest.py` cache entries per variant, and `tests/cli/test_add_command.py` add-path coverage. 10. If any generated-project fix happened in a scratch/prototype project rather than directly in the template, backport it into `aegis/templates/` before finishing - templates are the source of truth. 11. Once the docs page exists, add `docs/components//` (or `.md`), wire it into the root `mkdocs.yml` nav, and set `docs_path` on the spec. 12. Run the gates below and fix anything red. ## Gates - `make check` (lint, typecheck, test) must pass. - `make test-stacks-quick` while iterating (fast feedback: runs the stack validation phase against a representative subset - base, everything, insights). - `make test-stacks-full` before calling the work done (runs the generation-only `test-stacks` pass, the slow `test-stacks-build` validation pass, and the kitchen-sink `everything` stack). ## Pitfalls - Template changes render from the committed git state unless you pass `aegis init --dev` (working tree) or commit first; testing a template edit with plain `aegis init` on an uncommitted change silently uses stale content. - Copier conditional filenames are banned (broke on Windows); gate content with a full-body `{% if %}...{% endif %}` wrap inside the file instead of naming the file conditionally. - `copier` is pinned below 9.15 in `pyproject.toml` (9.15+ relocates `.copier-answers.yml`, breaking `aegis update`); a dependabot ignore rule guards the pin - do not "fix"/bump it even if a bot PR proposes it. - `.copier-answers.yml.jinja` looks incomplete on purpose: fields gated by a copier.yml `when:` clause (like `worker_backend`, `scheduler_backend`, `include_oauth`) are omitted from it, because Copier itself drops conditional answers on render. Don't "fix" this by adding them - the actual persistence path is `copier_manager.py`'s post-generation backfill, which patches any `copier_data` key Copier dropped directly into the written `.copier-answers.yml`. - `FileManifest.primary` on the spec is the single source `get_component_file_mapping()` derives from. A file present in the template but missing from `primary` renders correctly on fresh `init` (Copier just includes it) but is silently skipped by `aegis add`/`aegis remove` on an existing project - there is no equivalent to `add-service`'s `SERVICE_INVOCATIONS` regen-gap guard for components today, so cover this with explicit `tests/cli/test_add_command.py` assertions instead. - The worker backend suffix-rename in `post_gen_tasks.cleanup_components` (`_rename_backend_files` / `_remove_backend_files`) is the concrete model for a variant axis that ships parallel source files (`registry_dramatiq.py`, `registry_taskiq.py`, canonical `registry.py`): at generation time it renames the chosen backend's `*_.py` files to their canonical names and deletes the other backends' files. Skipping this for a new axis leaves all variants' files on disk simultaneously, each importing modules that shadow each other. - The same function distinguishes **init** (backend-specific source files still present, e.g. `*_dramatiq.py`) from **update** (only canonical files remain from a prior run) by checking whether any `*_.py` files exist before renaming. Running the arq-cleanup unconditionally on update would delete the canonical files a previous run already renamed into place, which is the exact bug the init-vs-update branch exists to prevent; a new variant axis must preserve it. - `aegis/commands/manual_updater.py`'s `add_component` has **no** knowledge of `worker_backend` today: `aegis add worker[dramatiq]` on an existing project always installs the arq default, silently discarding the bracket value. Don't assume bracket syntax "just works" for a new axis on the add path - verify (and if needed, add) the threading in `add.py` and `manual_updater.py` explicitly; it is not inherited for free from the `aegis init` path. - `docs_path` on the spec is pinned by `tests/core/test_spec_docs_paths.py` to a real file under this repo's root `/docs` (either `docs/.md` or `docs//index.md`) - setting it before the docs page exists fails that test. - New i18n keys get real strings in `aegis/i18n/locales/en.py` and the same keys with English-placeholder values in every other locale in the same change (required for `tests/core/test_i18n.py` parity); real translation is left to a separate translator-reviewed pass. - A component that owns tables (like scheduler's Postgres-only execution history) generates its migration only when the chosen backend needs persistence - model the `scheduler_backend != "memory"` gating in `get_services_needing_migrations` and the `needs_migrations` set in `post_gen_tasks.cleanup_components`; forgetting either leaves `alembic/` either missing when needed or present with no matching migration.